diff --git a/.gitignore b/.gitignore
index d90e245d..42f0b63a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,16 +1,25 @@
-# Temporary files and dependencies
+# Temporary files
+
+/release/
+/documentation/
+/manual/
+/tests/coverage/
+/dist/arch/arsse/
+/dist/arch/src/
+/dist/arch/pkg/
+/dist/man/
+/arsse.db*
+/config.php
+/.php_cs.cache
+/tests/.phpunit.result.cache
+
+# Dependencies
/vendor/
/vendor-bin/*/vendor
/node_modules
-/documentation/
-/manual/
-/tests/coverage/
-/arsse.db*
-/config.php
-/.php_cs.cache
+/yarn.lock
/yarn-error.log
-/tests/.phpunit.result.cache
# Windows files
@@ -26,7 +35,6 @@ $RECYCLE.BIN/
.DS_Store
.AppleDouble
.LSOverride
-Icon
._*
.Spotlight-V100
.Trashes
@@ -37,6 +45,7 @@ Icon
*.zip
*.7z
*.tar.gz
+*.tar.xz
*.tgz
*.deb
*.rpm
diff --git a/CHANGELOG b/CHANGELOG
index f679cfa1..24312f29 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,60 @@
+Version 0.10.0 (2021-07-11)
+===========================
+
+New features:
+- Complete Unix manual page
+- Support for running service as a forking daemon
+- Respond to TERM and HUP signals when possible
+
+Changes:
+- Packages for Debian and related are now available (see manual for details)
+
+Version 0.9.2 (2021-05-25)
+==========================
+
+Bug fixes:
+- Do not fail adding users to an empty database (regression since 0.9.0)
+- Cleanly ignore unknown configuration properties
+- Set access mode to rw-r---- when creating SQLite databases
+
+Changes:
+- Packages for Arch Linux are now available (see manual for details)
+- Numerous improvements to the manual
+
+Version 0.9.1 (2021-03-18)
+==========================
+
+Bug fixes:
+- Respond to PUT requests with 201 rather than 200 in Miniflux
+
+Changes:
+- Correct Web server configuration in manual
+
+Version 0.9.0 (2021-03-06)
+==========================
+
+New features:
+- Support for the Miniflux protocol (see manual for details)
+- Support for API level 15 of Tiny Tiny RSS
+- Support for feed icons in Fever
+- Command-line functionality for managing user metadata
+- Command-line functionality for managing Miniflux login tokens
+
+Bug fixes:
+- Further relax Fever HTTP correctness, to fix more clients
+- Use icons specified in Atom feeds when available
+- Do not return null as subscription unread count
+- Explicitly forbid U+003A COLON and control characters in usernames, for
+ compatibility with RFC 7617
+- Never return 401 in response to an OPTIONS request
+- Accept "t" and "f" as booleans in Tiny Tiny RSS
+
+Changes:
+- Administrator account requirements for Nextcloud News functionality are
+ now enforced
+- E_DEPRECATED is now suppressed for compatibility with PHP 8 until affected
+ dependencies can be replaced
+
Version 0.8.5 (2020-10-27)
==========================
@@ -84,7 +141,7 @@ Bug fixes:
Version 0.6.1 (2019-01-23)
==========================
-Bug Fixes:
+Bug fixes:
- Unify SQL timeout settings
- Correctly escape shell command in subprocess service driver
- Correctly allow null time intervals in configuration when appropriate
@@ -204,4 +261,5 @@ Bug fixes:
Version 0.1.0 (2017-08-29)
==========================
-Initial release
+New features:
+- Initial release
diff --git a/README.md b/README.md
index 74831aa4..d9befc72 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ Information on how to install and use the software can be found in [the manual](
The main repository for The Arsse can be found at [code.mensbeam.com](https://code.mensbeam.com/MensBeam/arsse/), with a mirror also available [at GitHub](https://github.com/mensbeam/arsse/). The GitHub mirror does not accept bug reports, but the two should otherwise be equivalent.
-[Composer](https://getcomposer.org/) is required to manage PHP dependencies. After cloning the repository or downloading a source code tarball, running `composer install` will download all the required dependencies, and will advise if any PHP extensions need to be installed. If not installing as a programming environment, running `composer install --no-dev` is recommended.
+[Composer](https://getcomposer.org/) is required to manage PHP dependencies. After cloning the repository or downloading a source code tarball, running `composer install` will download all the required dependencies, and will advise if any PHP extensions need to be installed. If not installing as a programming environment, running `composer install -o --no-dev --no-scripts` is recommended.
# Repository structure
@@ -34,13 +34,15 @@ Also necessary to the functioning of the application is the `/vendor/` directory
The `/locale/` and `/sql/` directories contain human-language files and database schemata, both of which are occasionally used by the application in the course of execution. The `/www/` directory serves as a document root for a few static files to be made available to users by a Web server.
-The `/dist/` directory, on the other hand, contains samples of configuration for Web servers and init systems. These are not used by The Arsse itself, but are merely distributed with it for reference.
+The `/dist/` directory, on the other hand, contains general and system-specific build files, and samples of configuration for Web servers and other system integration. These are not used by The Arsse itself, but are used during the process of preparing new releases for supported operating systems.
## Documentation
The source text for The Arsse's manual can be found in `/docs/`, with pages written in [Markdown](https://spec.commonmark.org/current/) and converted to HTML [with Daux](#building-the-manual). If a static manual is generated its files will appear under `/manual/`.
-In addition to the manual the files `/README.md` (this file), `/CHANGELOG`, `/UPGRADING`, `/LICENSE`, and `/AUTHORS` also document various things about the software, rather than the software itself.
+The Arsse also has a UNIX manual page, also written in Markdown, which can be found under `/manpages/`. [Pandoc](https://pandoc.org/) is needed to convert it to the appropriate format, with the results stored under `/dist/man/`.
+
+In addition to the manuals the files `/README.md` (this file), `/CHANGELOG`, `/UPGRADING`, `/LICENSE`, and `/AUTHORS` also document various things about the software, rather than the software itself.
## Tests
@@ -50,7 +52,7 @@ The `/tests/` directory contains everything related to automated testing. It is
|--------------------|------------------------------------------------------------------------------------|
| `cases/` | The test cases themselves, organized in roughly the same structure as the code |
| `coverage/` | (optional) Generated code coverage reports |
-| `docroot/` | Sample documents used in some tests, to be returned by the PHP's basic HTTP server |
+| `docroot/` | Sample documents used in some tests, to be returned by PHP's basic HTTP server |
| `lib/` | Supporting classes which do not contain test cases |
| `bootstrap.php` | Bootstrap script, equivalent to `/arsse.php`, but for tests |
| `phpunit.dist.xml` | PHPUnit configuration file |
@@ -74,7 +76,7 @@ The `/vendor-bin/` directory houses the files needed for the tools used in The A
| `/robo` | Simple wrapper for executing Robo on POSIX systems |
| `/robo.bat` | Simple wrapper for executing Robo on Windows |
-In addition the files `/package.json`, `/yarn.lock`, and `/postcss.config.js` as well as the `/node_modules/` directory are used by [Yarn](https://yarnpkg.com/) and [PostCSS](https://postcss.org/) when modifying the stylesheet for The Arsse's manual.
+In addition the files `/package.json` and `/postcss.config.js` as well as the `/node_modules/` directory are used by [Yarn](https://yarnpkg.com/) and [PostCSS](https://postcss.org/) when modifying the stylesheet for The Arsse's manual.
# Common tasks
@@ -105,15 +107,20 @@ The Arsse's user manual, made using [Daux](https://daux.io/), can be compiled by
The manual employs a custom theme derived from the standard Daux theme. If the standard Daux theme receives improvements, the custom theme can be rebuilt by running `./robo manual:theme`. This requires that [NodeJS](https://nodejs.org) and [Yarn](https://yarnpkg.com/) be installed, but JavaScript tools are not required to modify The Arsse itself, nor the content of the manual.
+## Building the man page
+
+The Arsse's UNIX manual page is authored in Markdown, and must be converted to the native roff format using [Pandoc](https://pandoc.org/). This can be done by running `./robo manpage`, which will output appropriate files to `/dist/man/`. The conversion should not be done manually as there is post-processing required for optimal output.
+
## Packaging a release
-Producing a release package is done by running `./robo package`. This performs the following operations:
+Producing release packages is done by running `./robo package`. This performs the following operations:
-- Duplicates a working tree with the commit (usually a release tag) to package
-- Generates the manual
+- Duplicates a [Git](https://git-scm.com/) working tree with the commit (usually a release tag) to package
+- Generates UNIX manual pages with [Pandoc](https://pandoc.org/)
+- Generates the HTML manual
- Installs runtime Composer dependencies with an optimized autoloader
- Deletes numerous unneeded files
- Exports the default configuration of The Arsse to a file
- Compresses the remaining files into a tarball
-
-Due to the first step, [Git](https://git-scm.com/) is required to package a release.
+- Produces a binary package for Arch Linux, if possible
+- Produces source and binary packages for Debian using [pbuilder](https://pbuilder-team.pages.debian.net/pbuilder/), if possible
diff --git a/RoboFile.php b/RoboFile.php
index 17456c10..63e14910 100644
--- a/RoboFile.php
+++ b/RoboFile.php
@@ -1,11 +1,14 @@
taskExec($executor)->option("-d", "zend.assertions=1")->arg($execpath)->option("-c", $confpath)->args(array_merge($set, $args))->run();
}
+ protected function commitVersion(?string $commit): array {
+ $target = $commit ?? $this->askDefault("Reference commit:", "HEAD");
+ $base = escapeshellarg(BASE);
+ $blackhole = $this->blackhole();
+ // get useable version strings from Git
+ $version = trim(`git -C $base describe --tags $target $blackhole`);
+ if (!$version) {
+ throw new \Exception("Commit reference invalid");
+ }
+ return [$target, $version];
+ }
+
+ protected function toolExists(string ...$binary): bool {
+ $blackhole = $this->blackhole(IS_WIN);
+ foreach ($binary as $bin) {
+ if (
+ (IS_WIN && (!exec(escapeshellarg($bin)." --help $blackhole", $junk, $status) || $status))
+ || (!IS_WIN && (!exec("which ".escapeshellarg($bin)." $blackhole", $junk, $status) || $status))
+ ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
/** Packages a given commit of the software into a release tarball
*
- * The version to package may be any Git tree-ish identifier: a tag, a branch,
+ * The commit to package may be any Git tree-ish identifier: a tag, a branch,
* or any commit hash. If none is provided on the command line, Robo will prompt
- * for a commit to package; the default is "head".
+ * for a commit to package; the default is "HEAD".
*
* Note that while it is possible to re-package old versions, the resultant tarball
* may not be equivalent due to subsequent changes in the exclude list, or because
* of new tooling.
*/
- public function package(string $version = null): Result {
+ public function packageGeneric(string $commit = null): Result {
+ if (!$this->toolExists("git", "pandoc")) {
+ throw new \Exception("Git and Pandoc are required in PATH to produce generic release tarballs");
+ }
// establish which commit to package
- $version = $version ?? $this->askDefault("Commit to package:", "HEAD");
- $archive = BASE."arsse-$version.tar.gz";
+ [$commit, $version] = $this->commitVersion($commit);
+ $archVersion = preg_replace('/^([^-]+)-(\d+)-(\w+)$/', "$1.r$2.$3", $version);
+ // name the generic release tarball
+ $tarball = BASE."release/$version/arsse-$version.tar.gz";
// start a collection
$t = $this->collectionBuilder();
// create a temporary directory
$dir = $t->tmpDir().\DIRECTORY_SEPARATOR;
// create a Git worktree for the selected commit in the temp location
- $t->taskExec("git worktree add ".escapeshellarg($dir)." ".escapeshellarg($version));
- // perform Composer installation in the temp location with dev dependencies
- $t->taskComposerInstall()->dir($dir);
- // generate the manual
- $t->taskExec(escapeshellarg($dir."robo")." manual")->dir($dir);
- // perform Composer installation in the temp location for final output
- $t->taskComposerInstall()->dir($dir)->noDev()->optimizeAutoloader()->arg("--no-scripts");
- // delete unwanted files
- $t->taskFilesystemStack()->remove([
- $dir.".git",
- $dir.".gitignore",
- $dir.".gitattributes",
- $dir."composer.json",
- $dir."composer.lock",
- $dir.".php_cs.dist",
- $dir."phpdoc.dist.xml",
- $dir."build.xml",
- $dir."RoboFile.php",
- $dir."CONTRIBUTING.md",
- $dir."docs",
- $dir."tests",
- $dir."vendor-bin",
- $dir."vendor/bin",
- $dir."robo",
- $dir."robo.bat",
- $dir."package.json",
- $dir."yarn.lock",
- $dir."postcss.config.js",
- ]);
- // generate a sample configuration file
- $t->taskExec(escapeshellarg(\PHP_BINARY)." arsse.php conf save-defaults config.defaults.php")->dir($dir);
- // package it all up
- $t->taskPack($archive)->addDir("arsse", $dir);
- // execute the collection
+ $result = $this->taskExec("git worktree add ".escapeshellarg($dir)." ".escapeshellarg($version))->dir(BASE)->run();
+ if ($result->getExitCode() > 0) {
+ return $result;
+ }
+ try {
+ // generate the Debian changelog; this also validates our original changelog
+ $debianChangelog = $this->changelogDebian($this->changelogParse(file_get_contents($dir."CHANGELOG"), $version), $version);
+ // save commit description to VERSION file for reference
+ $t->addTask($this->taskWriteToFile($dir."VERSION")->text($version));
+ // patch the Arch PKGBUILD file with the correct version string
+ $t->addTask($this->taskReplaceInFile($dir."dist/arch/PKGBUILD")->regex('/^pkgver=.*$/m')->to("pkgver=$archVersion"));
+ // patch the Arch PKGBUILD file with the correct source file
+ $t->addTask($this->taskReplaceInFile($dir."dist/arch/PKGBUILD")->regex('/^source=\("arsse-[^"]+"\)$/m')->to('source=("'.basename($tarball).'")'));
+ // save the Debian-format changelog
+ $t->addTask($this->taskWriteToFile($dir."dist/debian/changelog")->text($debianChangelog));
+ // perform Composer installation in the temp location with dev dependencies
+ $t->addTask($this->taskComposerInstall()->arg("-q")->dir($dir));
+ // generate manpages
+ $t->addTask($this->taskExec("./robo manpage")->dir($dir));
+ // generate the HTML manual
+ $t->addTask($this->taskExec("./robo manual -q")->dir($dir));
+ // perform Composer installation in the temp location for final output
+ $t->addTask($this->taskComposerInstall()->dir($dir)->noDev()->optimizeAutoloader()->arg("--no-scripts")->arg("-q"));
+ // delete unwanted files
+ $t->addTask($this->taskFilesystemStack()->remove([
+ $dir.".git",
+ $dir.".gitignore",
+ $dir.".gitattributes",
+ $dir."dist/debian/.gitignore",
+ $dir."composer.json",
+ $dir."composer.lock",
+ $dir.".php_cs.dist",
+ $dir."phpdoc.dist.xml",
+ $dir."build.xml",
+ $dir."RoboFile.php",
+ $dir."CONTRIBUTING.md",
+ $dir."docs",
+ $dir."manpages",
+ $dir."tests",
+ $dir."vendor-bin",
+ $dir."vendor/bin",
+ $dir."robo",
+ $dir."robo.bat",
+ $dir."package.json",
+ $dir."yarn.lock",
+ $dir."postcss.config.js",
+ ]));
+ $t->addCode(function() use ($dir) {
+ // Remove files which lintian complains about; they're otherwise harmless
+ $files = [];
+ foreach (new \CallbackFilterIterator(new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir."vendor", \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS)), function($v, $k, $i) {
+ return preg_match('/\/\.git(?:ignore|attributes|modules)$/D', $v);
+ }) as $f) {
+ $files[] = $f;
+ }
+ return $this->taskFilesystemStack()->remove($files)->run();
+ });
+ // generate a sample configuration file
+ $t->addTask($this->taskExec(escapeshellarg(\PHP_BINARY)." arsse.php conf save-defaults config.defaults.php")->dir($dir));
+ // remove any existing archive
+ $t->addTask($this->taskFilesystemStack()->remove($tarball));
+ // package it all up
+ $t->addTask($this->taskFilesystemStack()->mkdir(dirname($tarball)));
+ $t->addTask($this->taskPack($tarball)->addDir("arsse", $dir));
+ // execute the collection
+ $result = $t->run();
+ } finally {
+ // remove the Git worktree
+ $this->taskFilesystemStack()->remove($dir)->run();
+ $this->taskExec("git worktree prune")->dir(BASE)->run();
+ }
+ return $result;
+ }
+
+ /** Packages a given commit of the software into an Arch package
+ *
+ * The commit to package may be any Git tree-ish identifier: a tag, a branch,
+ * or any commit hash. If none is provided on the command line, Robo will prompt
+ * for a commit to package; the default is "HEAD".
+ *
+ * The Arch base-devel group should be installed for this.
+ */
+ public function packageArch(string $commit = null): Result {
+ if (!$this->toolExists("git", "makepkg", "updpkgsums")) {
+ throw new \Exception("Git, makepkg, and updpkgsums are required in PATH to produce Arch packages");
+ }
+ // establish which commit to package
+ [$commit, $version] = $this->commitVersion($commit);
+ $tarball = BASE."release/$version/arsse-$version.tar.gz";
+ $dir = dirname($tarball).\DIRECTORY_SEPARATOR;
+ // start a collection
+ $t = $this->collectionBuilder();
+ // build the generic release tarball if it doesn't exist
+ if (!file_exists($tarball)) {
+ $t->addTask($this->taskExec(BASE."robo package:generic $commit"));
+ }
+ // extract the PKGBUILD from the tarball
+ $t->addCode(function() use ($tarball, $dir) {
+ // because Robo doesn't support extracting a single file we have to do it ourselves
+ (new \Archive_Tar($tarball))->extractList("arsse/dist/arch/PKGBUILD", $dir, "arsse/dist/arch/", false);
+ // perform a do-nothing filesystem operation since we need a Robo task result
+ return $this->taskFilesystemStack()->chmod($dir."PKGBUILD", 0644)->run();
+ })->completion($this->taskFilesystemStack()->remove($dir."PKGBUILD"));
+ // build the package
+ $t->addTask($this->taskExec("makepkg -Ccf")->dir($dir));
+ return $t->run();
+ }
+
+ /** Packages a given commit of the software into source and binary Debian packages
+ *
+ * The commit to package may be any Git tree-ish identifier: a tag, a branch,
+ * or any commit hash. If none is provided on the command line, Robo will prompt
+ * for a commit to package; the default is "HEAD".
+ *
+ * The pbuilder tool should be installed for this.
+ */
+ public function packageDebian(string $commit = null): Result {
+ if (!$this->toolExists("git", "sudo", "pbuilder")) {
+ throw new \Exception("Git, sudo, and pbuilder are required in PATH to produce Debian packages");
+ }
+ // establish which commit to package
+ [$commit, $version] = $this->commitVersion($commit);
+ $tarball = BASE."release/$version/arsse-$version.tar.gz";
+ // define some more variables
+ $tgz = BASE."release/pbuilder-arsse.tgz";
+ $bind = dirname($tarball);
+ $script = BASE."dist/debian/pbuilder.sh";
+ $user = trim(`id -un`);
+ $group = trim(`id -gn`);
+ // start a task collection
+ $t = $this->collectionBuilder();
+ // check that the pbuilder base exists and create it if it does not
+ if (!file_exists($tgz)) {
+ $t->addTask($this->taskFilesystemStack()->mkdir(BASE."release"));
+ $t->addTask($this->taskExec('sudo pbuilder create --basetgz '.escapeshellarg($tgz).' --mirror http://ftp.ca.debian.org/debian/ --extrapackages "debhelper devscripts lintian"'));
+ }
+ // build the generic release tarball if it doesn't exist
+ if (!file_exists($tarball)) {
+ $t->addTask($this->taskExec(BASE."robo package:generic $commit"));
+ }
+ // build the packages
+ $t->addTask($this->taskExec('sudo pbuilder execute --basetgz '.escapeshellarg($tgz).' --bindmounts '.escapeshellarg($bind).' -- '.escapeshellarg($script).' '.escapeshellarg("$bind/".basename($tarball))));
+ // take ownership of the output files
+ $t->addTask($this->taskExec("sudo chown -R $user:$group ".escapeshellarg($bind)));
+ return $t->run();
+ }
+
+ /** Generates all possible package types for a given commit of the software
+ *
+ * The commit to package may be any Git tree-ish identifier: a tag, a branch,
+ * or any commit hash. If none is provided on the command line, Robo will prompt
+ * for a commit to package; the default is "HEAD".
+ *
+ * Generic release tarballs will always be generated, but distribution-specific
+ * packages are skipped when the required tools are not available
+ */
+ public function package(string $commit = null): Result {
+ if (!$this->toolExists("git")) {
+ throw new \Exception("Git is required in PATH to produce packages");
+ }
+ [$commit,] = $this->commitVersion($commit);
+ // determine whether the distribution-specific packages can be built
+ $dist = [
+ 'Arch' => $this->toolExists("git", "makepkg", "updpkgsums"),
+ 'Debian' => $this->toolExists("git", "sudo", "pbuilder"),
+ ];
+ // start a collection
+ $t = $this->collectionBuilder();
+ // build the generic release tarball
+ $t->addTask($this->taskExec(BASE."robo package:generic $commit"));
+ // build other packages
+ foreach ($dist as $distro => $run) {
+ if ($run) {
+ $subcmd = strtolower($distro);
+ $t->addTask($this->taskExec(BASE."robo package:$subcmd $commit"));
+ }
+ }
$out = $t->run();
- // clean the Git worktree list
- $this->_exec("git worktree prune");
+ // note any packages which were not built
+ foreach ($dist as $distro => $run) {
+ if (!$run) {
+ $this->say("Packages for $distro skipped");
+ }
+ }
return $out;
}
@@ -229,6 +409,9 @@ class RoboFile extends \Robo\Tasks {
* Daux's theme changes
*/
public function manualTheme(array $args): Result {
+ if (!$this->toolExists("yarn")) {
+ throw new \Exception("Yarn is required in PATH to update the Daux theme");
+ }
$postcss = escapeshellarg(norm(BASE."node_modules/.bin/postcss"));
$themesrc = norm(BASE."docs/theme/src/").\DIRECTORY_SEPARATOR;
$themeout = norm(BASE."docs/theme/arsse/").\DIRECTORY_SEPARATOR;
@@ -246,4 +429,144 @@ class RoboFile extends \Robo\Tasks {
// execute the collection
return $t->run();
}
+
+ /** Generates the "arsse" command's manual page (UNIX man page)
+ *
+ * This requires that the Pandoc document converter be installed and
+ * available in $PATH.
+ */
+ public function manpage(): Result {
+ if (!$this->toolExists("pandoc")) {
+ throw new \Exception("Pandoc is required in PATH to generate manual pages");
+ }
+ $t = $this->collectionBuilder();
+ $man = [
+ 'en' => "man1/arsse.1",
+ ];
+ foreach ($man as $src => $out) {
+ $src = BASE."manpages/$src.md";
+ $out = BASE."dist/man/$out";
+ $t->addTask($this->taskFilesystemStack()->mkdir(dirname($out), 0755));
+ $t->addTask($this->taskExec("pandoc -s -f markdown-smart -t man -o ".escapeshellarg($out)." ".escapeshellarg($src)));
+ $t->addTask($this->taskReplaceInFile($out)->regex('/\.\n(?!\.)/s')->to(". "));
+ }
+ return $t->run();
+ }
+
+ protected function changelogParse(string $text, string $targetVersion): array {
+ $lines = preg_split('/\r?\n/', $text);
+ $version = "";
+ $section = "";
+ $out = [];
+ $entry = [];
+ $expected = ["version"];
+ for ($a = 0; $a < sizeof($lines);) {
+ $l = rtrim($lines[$a++]);
+ if (in_array("version", $expected) && preg_match('/^Version (\d+(?:\.\d+)*) \(([\d\?]{4}-[\d\?]{2}-[\d\?]{2})\)\s*$/D', $l, $m)) {
+ $version = $m[1];
+ if (!preg_match('/^\d{4}-\d{2}-\d{2}$/D', $m[2])) {
+ // uncertain dates are allowed only for the top version, and only if it does not match the target version (otherwise we have forgotten to set the correct date before tagging)
+ if (!$out && $targetVersion !== $version) {
+ // use today's date; local time is fine
+ $date = date("Y-m-d");
+ } else {
+ throw new \Exception("CHANGELOG: Date at line $a is incomplete");
+ }
+ } else {
+ $date = $m[2];
+ }
+ if ($entry) {
+ $out[] = $entry;
+ }
+ $entry = ['version' => $version, 'date' => $date, 'features' => [], 'fixes' => [], 'changes' => []];
+ $expected = ["separator"];
+ } elseif (in_array("separator", $expected) && preg_match('/^=+/', $l)) {
+ $length = strlen($lines[$a - 2]);
+ if (strlen($l) !== $length) {
+ throw new \Exception("CHANGELOG: Separator at line $a is of incorrect length");
+ }
+ $expected = ["blank line"];
+ $section = "";
+ } elseif (in_array("blank line", $expected) && $l === "") {
+ $expected = [
+ '' => ["features section", "fixes section", "changes section"],
+ 'features' => ["fixes section", "changes section", "version"],
+ 'fixes' => ["changes section", "version"],
+ 'changes' => ["version"],
+ ][$section];
+ $expected[] = "end-of-file";
+ } elseif (in_array("features section", $expected) && $l === "New features:") {
+ $section = "features";
+ $expected = ["item"];
+ } elseif (in_array("fixes section", $expected) && $l === "Bug fixes:") {
+ $section = "fixes";
+ $expected = ["item"];
+ } elseif (in_array("changes section", $expected) && $l === "Changes:") {
+ $section = "changes";
+ $expected = ["item"];
+ } elseif (in_array("item", $expected) && preg_match('/^- (\w.*)$/D', $l, $m)) {
+ $entry[$section][] = $m[1];
+ $expected = ["item", "continuation", "blank line"];
+ } elseif (in_array("continuation", $expected) && preg_match('/^ (\w.*)$/D', $l, $m)) {
+ $last = sizeof($entry[$section]) - 1;
+ $entry[$section][$last] .= "\n".$m[1];
+ } else {
+ if (sizeof($expected) > 1) {
+ throw new \Exception("CHANGELOG: Expected one of [".implode(", ", $expected)."] at line $a");
+ } else {
+ throw new \Exception("CHANGELOG: Expected ".$expected[0]." at line $a");
+ }
+ }
+ }
+ if (!in_array("end-of-file", $expected)) {
+ if (sizeof($expected) > 1) {
+ throw new \Exception("CHANGELOG: Expected one of [".implode(", ", $expected)."] at end of file");
+ } else {
+ throw new \Exception("CHANGELOG: Expected ".$expected[0]." at end of file");
+ }
+ }
+ $out[] = $entry;
+ return $out;
+ }
+
+ protected function changelogDebian(array $log, string $targetVersion): string {
+ $latest = $log[0]['version'];
+ $baseVersion = preg_replace('/^(\d+(?:\.\d+)*).*/', "$1", $targetVersion);
+ if ($baseVersion !== $targetVersion && version_compare($latest, $baseVersion, ">")) {
+ // if the changelog contains an entry for a future version, change its version number to match the target version instead of using the future version
+ $log[0]['version'] = $targetVersion;
+ $log[0]['distribution'] = "UNRELEASED";
+ } elseif ($baseVersion !== $targetVersion) {
+ // otherwise synthesize a changelog entry for the changes since the last tag
+ array_unshift($log, ['version' => $targetVersion, 'date' => date("Y-m-d"), 'features' => [], 'fixes' => [], 'changes' => ["Unspecified changes"], 'distribution' => "UNRELEASED"]);
+ }
+ $out = "";
+ foreach ($log as $entry) {
+ // normalize the version string
+ preg_match('/^(\d+(?:\.\d+)*)(?:-(\d+)-.+)?$/D', $entry['version'], $m);
+ $version = $m[1]."-".($m[2] ?: "1");
+ // output the entry
+ $out .= "arsse ($version) ".($entry['distribution'] ?? "unstable")."; urgency=low\n";
+ if ($entry['features']) {
+ $out .= "\n";
+ foreach ($entry['features'] as $item) {
+ $out .= " * ".trim(preg_replace("/^/m", " ", $item))."\n";
+ }
+ }
+ if ($entry['fixes']) {
+ $out .= "\n";
+ foreach ($entry['fixes'] as $item) {
+ $out .= " * ".trim(preg_replace("/^/m", " ", $item))."\n";
+ }
+ }
+ if ($entry['changes']) {
+ $out .= "\n";
+ foreach ($entry['changes'] as $item) {
+ $out .= " * ".trim(preg_replace("/^/m", " ", $item))."\n";
+ }
+ }
+ $out .= "\n -- J. King ".\DateTimeImmutable::createFromFormat("Y-m-d", $entry['date'], new \DateTimeZone("UTC"))->format("D, d M Y")." 00:00:00 +0000\n\n";
+ }
+ return $out;
+ }
}
diff --git a/UPGRADING b/UPGRADING
index f18bf760..f6dcfff6 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -11,6 +11,23 @@ usually prudent:
`composer install -o --no-dev`
+Upgrading from 0.8.5 to 0.9.0
+=============================
+
+- The database schema has changed from rev6 to rev7; if upgrading the database
+ manually, apply the 6.sql file
+- Web server configuration has changed to accommodate Miniflux; the following
+ URL paths are affected:
+ - /v1/
+ - /version
+ - /healthcheck
+- Icons for existing feeds in Miniflux and Fever will only appear once the
+ feeds in question have been fetched and parsed after upgrade. This may take
+ some time to occur depending on how often the feed is updated
+- An administrator account is now required to refresh feeds via the
+ Nextcloud News protocol
+
+
Upgrading from 0.8.4 to 0.8.5
=============================
diff --git a/arsse.php b/arsse.php
index 546723b9..7e13cc39 100644
--- a/arsse.php
+++ b/arsse.php
@@ -13,17 +13,19 @@ require_once BASE."vendor".DIRECTORY_SEPARATOR."autoload.php";
ignore_user_abort(true);
ini_set("memory_limit", "-1");
ini_set("max_execution_time", "0");
+// FIXME: This is required by a dependency of Picofeed
+error_reporting(\E_ALL & ~\E_DEPRECATED);
if (\PHP_SAPI === "cli") {
- // initialize the CLI; this automatically handles --help and --version
+ // initialize the CLI; this automatically handles --help and --version else
+ Arsse::$obj = new Factory;
$cli = new CLI;
// handle other CLI requests; some do not require configuration
$exitStatus = $cli->dispatch();
exit($exitStatus);
} else {
// load configuration
- $conf = file_exists(BASE."config.php") ? new Conf(BASE."config.php") : new Conf;
- Arsse::load($conf);
+ Arsse::bootstrap();
// handle Web requests
$emitter = new \Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
$response = (new REST)->dispatch();
diff --git a/composer.json b/composer.json
index 26bab1c8..afab259e 100644
--- a/composer.json
+++ b/composer.json
@@ -18,10 +18,11 @@
],
"require": {
- "php": "^7.1",
+ "php": "^7.1 || ^8.0",
"ext-intl": "*",
"ext-json": "*",
"ext-hash": "*",
+ "ext-filter": "*",
"ext-dom": "*",
"nicolus/picofeed": "^0.1.43",
"hosteurope/password-generator": "1.*",
@@ -33,6 +34,9 @@
"require-dev": {
"bamarni/composer-bin-plugin": "*"
},
+ "suggest": {
+ "ext-pcntl": "To respond to signals, particular to reload configuration via SIGHUP"
+ },
"config": {
"platform": {
"php": "7.1.33"
diff --git a/composer.lock b/composer.lock
index 77d43df5..9f8f3712 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "9880398f241b2e782876bb510207cde7",
+ "content-hash": "c658930fbc56b2b2cf646e34c6a8d8d3",
"packages": [
{
"name": "docopt/docopt",
@@ -50,6 +50,10 @@
"cli",
"docs"
],
+ "support": {
+ "issues": "https://github.com/docopt/docopt.php/issues",
+ "source": "https://github.com/docopt/docopt.php/tree/1.0.4"
+ },
"time": "2019-12-03T02:48:46+00:00"
},
{
@@ -117,20 +121,24 @@
"rest",
"web service"
],
+ "support": {
+ "issues": "https://github.com/guzzle/guzzle/issues",
+ "source": "https://github.com/guzzle/guzzle/tree/6.5"
+ },
"time": "2020-06-16T21:01:06+00:00"
},
{
"name": "guzzlehttp/promises",
- "version": "1.4.0",
+ "version": "1.4.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
- "reference": "60d379c243457e073cff02bc323a2a86cb355631"
+ "reference": "8e7d04f1f6450fef59366c399cfad4b9383aa30d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/promises/zipball/60d379c243457e073cff02bc323a2a86cb355631",
- "reference": "60d379c243457e073cff02bc323a2a86cb355631",
+ "url": "https://api.github.com/repos/guzzle/promises/zipball/8e7d04f1f6450fef59366c399cfad4b9383aa30d",
+ "reference": "8e7d04f1f6450fef59366c399cfad4b9383aa30d",
"shasum": ""
},
"require": {
@@ -168,20 +176,24 @@
"keywords": [
"promise"
],
- "time": "2020-09-30T07:37:28+00:00"
+ "support": {
+ "issues": "https://github.com/guzzle/promises/issues",
+ "source": "https://github.com/guzzle/promises/tree/1.4.1"
+ },
+ "time": "2021-03-07T09:25:29+00:00"
},
{
"name": "guzzlehttp/psr7",
- "version": "1.7.0",
+ "version": "1.8.2",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
- "reference": "53330f47520498c0ae1f61f7e2c90f55690c06a3"
+ "reference": "dc960a912984efb74d0a90222870c72c87f10c91"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/psr7/zipball/53330f47520498c0ae1f61f7e2c90f55690c06a3",
- "reference": "53330f47520498c0ae1f61f7e2c90f55690c06a3",
+ "url": "https://api.github.com/repos/guzzle/psr7/zipball/dc960a912984efb74d0a90222870c72c87f10c91",
+ "reference": "dc960a912984efb74d0a90222870c72c87f10c91",
"shasum": ""
},
"require": {
@@ -239,7 +251,11 @@
"uri",
"url"
],
- "time": "2020-09-30T07:37:11+00:00"
+ "support": {
+ "issues": "https://github.com/guzzle/psr7/issues",
+ "source": "https://github.com/guzzle/psr7/tree/1.8.2"
+ },
+ "time": "2021-04-26T09:17:50+00:00"
},
{
"name": "hosteurope/password-generator",
@@ -279,6 +295,10 @@
}
],
"description": "Password generator for generating policy-compliant passwords.",
+ "support": {
+ "issues": "https://github.com/hosteurope/password-generator/issues",
+ "source": "https://github.com/hosteurope/password-generator/tree/master"
+ },
"time": "2016-12-08T09:32:12+00:00"
},
{
@@ -324,6 +344,10 @@
"keywords": [
"uuid"
],
+ "support": {
+ "issues": "https://github.com/JKingweb/DrUUID/issues",
+ "source": "https://github.com/JKingweb/DrUUID/tree/3.0.0"
+ },
"time": "2017-02-09T14:17:01+00:00"
},
{
@@ -399,6 +423,10 @@
"rfc7234",
"validation"
],
+ "support": {
+ "issues": "https://github.com/Kevinrob/guzzle-cache-middleware/issues",
+ "source": "https://github.com/Kevinrob/guzzle-cache-middleware/tree/master"
+ },
"time": "2017-08-17T12:23:43+00:00"
},
{
@@ -484,6 +512,14 @@
"psr-17",
"psr-7"
],
+ "support": {
+ "chat": "https://laminas.dev/chat",
+ "docs": "https://docs.laminas.dev/laminas-diactoros/",
+ "forum": "https://discourse.laminas.dev",
+ "issues": "https://github.com/laminas/laminas-diactoros/issues",
+ "rss": "https://github.com/laminas/laminas-diactoros/releases.atom",
+ "source": "https://github.com/laminas/laminas-diactoros"
+ },
"funding": [
{
"url": "https://funding.communitybridge.org/projects/laminas-project",
@@ -549,6 +585,14 @@
"psr-15",
"psr-7"
],
+ "support": {
+ "chat": "https://laminas.dev/chat",
+ "docs": "https://docs.laminas.dev/laminas-httphandlerrunner/",
+ "forum": "https://discourse.laminas.dev",
+ "issues": "https://github.com/laminas/laminas-httphandlerrunner/issues",
+ "rss": "https://github.com/laminas/laminas-httphandlerrunner/releases.atom",
+ "source": "https://github.com/laminas/laminas-httphandlerrunner"
+ },
"funding": [
{
"url": "https://funding.communitybridge.org/projects/laminas-project",
@@ -605,6 +649,14 @@
"security",
"xml"
],
+ "support": {
+ "chat": "https://laminas.dev/chat",
+ "docs": "https://docs.laminas.dev/laminas-xml/",
+ "forum": "https://discourse.laminas.dev",
+ "issues": "https://github.com/laminas/laminas-xml/issues",
+ "rss": "https://github.com/laminas/laminas-xml/releases.atom",
+ "source": "https://github.com/laminas/laminas-xml"
+ },
"time": "2019-12-31T18:05:42+00:00"
},
{
@@ -653,6 +705,12 @@
"laminas",
"zf"
],
+ "support": {
+ "forum": "https://discourse.laminas.dev/",
+ "issues": "https://github.com/laminas/laminas-zendframework-bridge/issues",
+ "rss": "https://github.com/laminas/laminas-zendframework-bridge/releases.atom",
+ "source": "https://github.com/laminas/laminas-zendframework-bridge"
+ },
"funding": [
{
"url": "https://funding.communitybridge.org/projects/laminas-project",
@@ -721,6 +779,9 @@
],
"description": "RSS/Atom parsing library",
"homepage": "https://github.com/nicolus/picoFeed",
+ "support": {
+ "source": "https://github.com/nicolus/picoFeed/tree/0.1.43"
+ },
"time": "2020-09-15T07:28:23+00:00"
},
{
@@ -773,6 +834,9 @@
"request",
"response"
],
+ "support": {
+ "source": "https://github.com/php-fig/http-factory/tree/master"
+ },
"time": "2019-04-30T12:38:16+00:00"
},
{
@@ -823,6 +887,9 @@
"request",
"response"
],
+ "support": {
+ "source": "https://github.com/php-fig/http-message/tree/master"
+ },
"time": "2016-08-06T14:39:51+00:00"
},
{
@@ -876,20 +943,24 @@
"response",
"server"
],
+ "support": {
+ "issues": "https://github.com/php-fig/http-server-handler/issues",
+ "source": "https://github.com/php-fig/http-server-handler/tree/master"
+ },
"time": "2018-10-30T16:46:14+00:00"
},
{
"name": "psr/log",
- "version": "1.1.3",
+ "version": "1.1.4",
"source": {
"type": "git",
"url": "https://github.com/php-fig/log.git",
- "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc"
+ "reference": "d49695b909c3b7628b6289db5479a1c204601f11"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc",
- "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11",
+ "reference": "d49695b909c3b7628b6289db5479a1c204601f11",
"shasum": ""
},
"require": {
@@ -913,7 +984,7 @@
"authors": [
{
"name": "PHP-FIG",
- "homepage": "http://www.php-fig.org/"
+ "homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for logging libraries",
@@ -923,7 +994,10 @@
"psr",
"psr-3"
],
- "time": "2020-03-23T09:12:05+00:00"
+ "support": {
+ "source": "https://github.com/php-fig/log/tree/1.1.4"
+ },
+ "time": "2021-05-03T11:20:27+00:00"
},
{
"name": "ralouphie/getallheaders",
@@ -963,20 +1037,24 @@
}
],
"description": "A polyfill for getallheaders.",
+ "support": {
+ "issues": "https://github.com/ralouphie/getallheaders/issues",
+ "source": "https://github.com/ralouphie/getallheaders/tree/develop"
+ },
"time": "2019-03-08T08:55:37+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
- "reference": "3b75acd829741c768bc8b1f84eb33265e7cc5117"
+ "reference": "65bd267525e82759e7d8c4e8ceea44f398838e65"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/3b75acd829741c768bc8b1f84eb33265e7cc5117",
- "reference": "3b75acd829741c768bc8b1f84eb33265e7cc5117",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/65bd267525e82759e7d8c4e8ceea44f398838e65",
+ "reference": "65bd267525e82759e7d8c4e8ceea44f398838e65",
"shasum": ""
},
"require": {
@@ -990,7 +1068,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1033,6 +1111,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1047,20 +1128,20 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-05-27T09:27:20+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
- "reference": "727d1096295d807c309fb01a851577302394c897"
+ "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/727d1096295d807c309fb01a851577302394c897",
- "reference": "727d1096295d807c309fb01a851577302394c897",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8590a5f561694770bdcd3f9b5c69dde6945028e8",
+ "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8",
"shasum": ""
},
"require": {
@@ -1072,7 +1153,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1114,6 +1195,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1128,20 +1212,20 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-02-19T12:13:01+00:00"
},
{
"name": "symfony/polyfill-php72",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php72.git",
- "reference": "cede45fcdfabdd6043b3592e83678e42ec69e930"
+ "reference": "9a142215a36a3888e30d0a9eeea9766764e96976"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/cede45fcdfabdd6043b3592e83678e42ec69e930",
- "reference": "cede45fcdfabdd6043b3592e83678e42ec69e930",
+ "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/9a142215a36a3888e30d0a9eeea9766764e96976",
+ "reference": "9a142215a36a3888e30d0a9eeea9766764e96976",
"shasum": ""
},
"require": {
@@ -1150,7 +1234,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1187,6 +1271,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php72/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1201,7 +1288,7 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-05-27T09:17:38+00:00"
}
],
"packages-dev": [
@@ -1249,6 +1336,10 @@
"isolation",
"tool"
],
+ "support": {
+ "issues": "https://github.com/bamarni/composer-bin-plugin/issues",
+ "source": "https://github.com/bamarni/composer-bin-plugin/tree/master"
+ },
"time": "2020-05-03T08:27:20+00:00"
}
],
@@ -1258,15 +1349,16 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
- "php": "^7.1",
+ "php": "^7.1 || ^8.0",
"ext-intl": "*",
"ext-json": "*",
"ext-hash": "*",
+ "ext-filter": "*",
"ext-dom": "*"
},
"platform-dev": [],
"platform-overrides": {
"php": "7.1.33"
},
- "plugin-api-version": "1.1.0"
+ "plugin-api-version": "2.1.0"
}
diff --git a/dist/apache.conf b/dist/apache.conf
deleted file mode 100644
index 3c27b5a2..00000000
--- a/dist/apache.conf
+++ /dev/null
@@ -1,23 +0,0 @@
-# N.B. the unix:/var/run/php/php7.2-fpm.sock path used repeatedly below will
-# vary from system to system and will be probably need to be changed
-
-
- ServerName localhost
- # adjust according to your installation path
- DocumentRoot /usr/share/arsse/www
-
- # adjust according to your installation path
- ProxyFCGISetEnvIf "true" SCRIPT_FILENAME "/usr/share/arsse/arsse.php"
- ProxyPreserveHost On
-
- # Nextcloud News v1.2, Tiny Tiny RSS API, TT-RSS newsfeed icons
-
- ProxyPass "unix:/var/run/php/php7.2-fpm.sock|fcgi://localhost/usr/share/arsse"
-
-
- # Nextcloud News API detection, Fever API
-
- # these locations should not be behind HTTP authentication
- ProxyPass "unix:/var/run/php/php7.2-fpm.sock|fcgi://localhost/usr/share/arsse"
-
-
diff --git a/dist/apache/arsse-loc.conf b/dist/apache/arsse-loc.conf
new file mode 100644
index 00000000..611c1fc6
--- /dev/null
+++ b/dist/apache/arsse-loc.conf
@@ -0,0 +1,34 @@
+# Nextcloud News protocol
+
+ ProxyPass ${ARSSE_PROXY}
+
+
+# Tiny Tiny RSS protocol
+
+ ProxyPass ${ARSSE_PROXY}
+
+
+# Tiny Tiny RSS feed icons
+
+ ProxyPass ${ARSSE_PROXY}
+
+
+# Fever protocol
+
+ ProxyPass ${ARSSE_PROXY}
+
+
+# Miniflux protocol
+
+ ProxyPass ${ARSSE_PROXY}
+
+
+# Miniflux version number
+
+ ProxyPass ${ARSSE_PROXY}
+
+
+# Miniflux "health check"
+
+ ProxyPass ${ARSSE_PROXY}
+
diff --git a/dist/apache/arsse.conf b/dist/apache/arsse.conf
new file mode 100644
index 00000000..b16c80e8
--- /dev/null
+++ b/dist/apache/arsse.conf
@@ -0,0 +1,11 @@
+DocumentRoot "/usr/share/arsse/www"
+
+ Require all granted
+
+
+Define ARSSE_PROXY "unix:/var/run/php/arsse.sock|fcgi://localhost/usr/share/arsse/"
+ProxyPreserveHost On
+ProxyFCGISetEnvIf "true" SCRIPT_FILENAME "/usr/share/arsse/arsse.php"
+ProxyFCGISetEnvIf "-n req('Authorization')" HTTP_AUTHORIZATION "%{req:Authorization}"
+
+Include "/etc/arsse/apache/arsse-loc.conf"
diff --git a/dist/apache/example.conf b/dist/apache/example.conf
new file mode 100644
index 00000000..0e1f356f
--- /dev/null
+++ b/dist/apache/example.conf
@@ -0,0 +1,9 @@
+
+ ServerName "news.example.com"
+ SSLEngine On
+
+ SSLCertificateFile "/etc/letsencrypt/live/news.example.com/fullchain.pem"
+ SSLCertificateKeyFile "/etc/letsencrypt/live/news.example.com/privkey.pem"
+
+ Include "/etc/arsse/apache/arsse.conf"
+
diff --git a/dist/arch/PKGBUILD b/dist/arch/PKGBUILD
new file mode 100644
index 00000000..872f06b7
--- /dev/null
+++ b/dist/arch/PKGBUILD
@@ -0,0 +1,57 @@
+# Maintainer: J. King
+pkgname="arsse"
+pkgver=0.9.2
+pkgrel=1
+epoch=
+pkgdesc="Multi-protocol RSS/Atom newsfeed synchronization server"
+arch=("any")
+url="https://thearsse.com/"
+license=("MIT")
+depends=()
+makedepends=()
+checkdepends=()
+optdepends=("nginx: HTTP server"
+ "apache>=2.4: HTTP server"
+ "percona-server: Alternate database"
+ "postgresql>=10: Alternate database"
+ "php-pgsql>=7.1: PostgreSQL database support")
+backup=("etc/webapps/arsse/config.php"
+ "etc/php/php-fpm.d/arsse.conf"
+ "etc/webapps/arsse/nginx/example.conf"
+ "etc/webapps/arsse/nginx/arsse.conf"
+ "etc/webapps/arsse/nginx/arsse-loc.conf"
+ "etc/webapps/arsse/nginx/arsse-fcgi.conf"
+ "etc/webapps/arsse/apache/example.conf"
+ "etc/webapps/arsse/apache/arsse.conf"
+ "etc/webapps/arsse/apache/arsse-loc.conf")
+source=("arsse-0.9.2.tar.gz")
+md5sums=("SKIP")
+
+package() {
+ # define runtime dependencies
+ depends=("php>=7.1" "php-intl>=7.1" "php-sqlite>=7.1" "php-fpm>=7.1")
+ # create most directories necessary for the final package
+ cd "$pkgdir"
+ mkdir -p "usr/share/webapps/arsse" "usr/share/doc/arsse" "usr/share/licenses/arsse" "usr/lib/systemd/system" "usr/lib/sysusers.d" "usr/lib/tmpfiles.d" "etc/php/php-fpm.d" "etc/webapps/arsse"
+ # copy requisite files
+ cd "$srcdir/arsse"
+ cp -r lib locale sql vendor www CHANGELOG UPGRADING README.md arsse.php "$pkgdir/usr/share/webapps/arsse"
+ cp -r manual/* "$pkgdir/usr/share/doc/arsse"
+ cp LICENSE AUTHORS "$pkgdir/usr/share/licenses/arsse"
+ cp dist/systemd/* "$pkgdir/usr/lib/systemd/system"
+ cp dist/sysuser.conf "$pkgdir/usr/lib/sysusers.d/arsse.conf"
+ cp dist/tmpfiles.conf "$pkgdir/usr/lib/tmpfiles.d/arsse.conf"
+ cp dist/php-fpm.conf "$pkgdir/etc/php/php-fpm.d/arsse.conf"
+ cp -r dist/man "$pkgdir/usr/share"
+ cp -r dist/nginx dist/apache config.defaults.php "$pkgdir/etc/webapps/arsse"
+ cd "$pkgdir"
+ # copy files requiring special permissions
+ cd "$srcdir/arsse"
+ install -Dm755 dist/arsse "$pkgdir/usr/bin/arsse"
+ install -Dm640 dist/config.php "$pkgdir/etc/webapps/arsse"
+ # patch generic configuration files to use Arch-specific paths and identifiers
+ sed -i -se 's/\/\(etc\|usr\/share\)\/arsse\//\/\1\/webapps\/arsse\//g' "$pkgdir/etc/webapps/arsse/nginx/"* "$pkgdir/etc/webapps/arsse/apache/"* "$pkgdir/usr/lib/tmpfiles.d/arsse.conf" "$pkgdir/usr/lib/systemd/system/"* "$pkgdir/usr/bin/"*
+ sed -i -se 's/\/var\/run\/php\//\/run\/php-fpm\//g' "$pkgdir/etc/webapps/arsse/nginx/"* "$pkgdir/etc/webapps/arsse/apache/"* "$pkgdir/etc/php/php-fpm.d/arsse.conf"
+ sed -i -se 's/www-data/http/g' "$pkgdir/etc/php/php-fpm.d/arsse.conf"
+ sed -i -e 's/^WorkingDirectory=.*$/WorkingDirectory=\/usr\/share\/webapps\/arsse/g' -e 's/^ConfigurationDirectory=.*$/ConfigurationDirectory=webapps\/arsse/g' "$pkgdir/usr/lib/systemd/system/arsse-fetch.service"
+}
diff --git a/dist/arch/PKGBUILD-git b/dist/arch/PKGBUILD-git
new file mode 100644
index 00000000..428456ca
--- /dev/null
+++ b/dist/arch/PKGBUILD-git
@@ -0,0 +1,74 @@
+# Maintainer: J. King
+pkgname="arsse-git"
+pkgver=0.9.2
+pkgrel=1
+epoch=
+pkgdesc="Multi-protocol RSS/Atom newsfeed synchronization server, bugfix-testing version"
+arch=("any")
+url="https://thearsse.com/"
+license=("MIT")
+provides=("arsse")
+conflicts=("arsse")
+depends=("php>=7.1" "php-intl>=7.1" "php-sqlite>=7.1")
+makedepends=("composer" "pandoc")
+checkdepends=()
+optdepends=("nginx: HTTP server"
+ "apache>=2.4: HTTP server"
+ "percona-server: Alternate database"
+ "postgresql>=10: Alternate database"
+ "php-pgsql>=7.1: PostgreSQL database support")
+backup=("etc/webapps/arsse/config.php"
+ "etc/php/php-fpm.d/arsse.conf"
+ "etc/webapps/arsse/nginx/example.conf"
+ "etc/webapps/arsse/nginx/arsse.conf"
+ "etc/webapps/arsse/nginx/arsse-loc.conf"
+ "etc/webapps/arsse/nginx/arsse-fcgi.conf"
+ "etc/webapps/arsse/apache/example.conf"
+ "etc/webapps/arsse/apache/arsse.conf"
+ "etc/webapps/arsse/apache/arsse-loc.conf")
+source=("git+https://code.mensbeam.com/MensBeam/arsse/")
+md5sums=("SKIP")
+
+pkgver() {
+ cd "arsse"
+ git describe --tags | sed 's/\([^-]*-g\)/r\1/;s/-/./g'
+}
+
+build() {
+ cd "$srcdir/arsse"
+ composer install
+ ./robo manpage
+ ./robo manual
+ composer install --no-dev -o --no-scripts
+ php arsse.php conf save-defaults config.defaults.php
+ rm -r vendor/bin
+}
+
+package() {
+ # define runtime dependencies
+ depends=("php>=7.1" "php-intl>=7.1" "php-sqlite>=7.1" "php-fpm>=7.1")
+ # create most directories necessary for the final package
+ cd "$pkgdir"
+ mkdir -p "usr/share/webapps/arsse" "usr/share/doc/arsse" "usr/share/licenses/arsse" "usr/lib/systemd/system" "usr/lib/sysusers.d" "usr/lib/tmpfiles.d" "etc/php/php-fpm.d" "etc/webapps/arsse"
+ # copy requisite files
+ cd "$srcdir/arsse"
+ cp -r lib locale sql vendor www CHANGELOG UPGRADING README.md arsse.php "$pkgdir/usr/share/webapps/arsse"
+ cp -r manual/* "$pkgdir/usr/share/doc/arsse"
+ cp LICENSE AUTHORS "$pkgdir/usr/share/licenses/arsse"
+ cp dist/systemd/* "$pkgdir/usr/lib/systemd/system"
+ cp dist/sysuser.conf "$pkgdir/usr/lib/sysusers.d/arsse.conf"
+ cp dist/tmpfiles.conf "$pkgdir/usr/lib/tmpfiles.d/arsse.conf"
+ cp dist/php-fpm.conf "$pkgdir/etc/php/php-fpm.d/arsse.conf"
+ cp -r dist/man "$pkgdir/usr/share"
+ cp -r dist/nginx dist/apache config.defaults.php "$pkgdir/etc/webapps/arsse"
+ cd "$pkgdir"
+ # copy files requiring special permissions
+ cd "$srcdir/arsse"
+ install -Dm755 dist/arsse "$pkgdir/usr/bin/arsse"
+ install -Dm640 dist/config.php "$pkgdir/etc/webapps/arsse"
+ # patch generic configuration files to use Arch-specific paths and identifiers
+ sed -i -se 's/\/\(etc\|usr\/share\)\/arsse\//\/\1\/webapps\/arsse\//g' "$pkgdir/etc/webapps/arsse/nginx/"* "$pkgdir/etc/webapps/arsse/apache/"* "$pkgdir/usr/lib/tmpfiles.d/arsse.conf" "$pkgdir/usr/lib/systemd/system/"* "$pkgdir/usr/bin/"*
+ sed -i -se 's/\/var\/run\/php\//\/run\/php-fpm\//g' "$pkgdir/etc/webapps/arsse/nginx/"* "$pkgdir/etc/webapps/arsse/apache/"* "$pkgdir/etc/php/php-fpm.d/arsse.conf"
+ sed -i -se 's/www-data/http/g' "$pkgdir/etc/php/php-fpm.d/arsse.conf"
+ sed -i -e 's/^WorkingDirectory=.*$/WorkingDirectory=\/usr\/share\/webapps\/arsse/g' -e 's/^ConfigurationDirectory=.*$/ConfigurationDirectory=webapps\/arsse/g' "$pkgdir/usr/lib/systemd/system/arsse-fetch.service"
+}
diff --git a/dist/arsse b/dist/arsse
new file mode 100644
index 00000000..987c1596
--- /dev/null
+++ b/dist/arsse
@@ -0,0 +1,10 @@
+#! /usr/bin/env php
+ "/var/lib/arsse/arsse.db",
+];
diff --git a/dist/debian/arsse.config b/dist/debian/arsse.config
new file mode 100644
index 00000000..6839c26b
--- /dev/null
+++ b/dist/debian/arsse.config
@@ -0,0 +1,16 @@
+#!/bin/sh
+
+set -e
+
+. /usr/share/debconf/confmodule
+
+# Set up dbconfig-common
+if test -f /usr/share/dbconfig-common/dpkg/config; then
+ . /usr/share/dbconfig-common/dpkg/config
+ dbc_dbtypes="sqlite3, pgsql, mysql"
+ dbc_authmethod_user="password"
+ dbc_go arsse "$@"
+fi
+
+# Prompt for dbconfig-common configuration
+db_go || true
diff --git a/dist/debian/arsse.dirs b/dist/debian/arsse.dirs
new file mode 100644
index 00000000..2e135526
--- /dev/null
+++ b/dist/debian/arsse.dirs
@@ -0,0 +1 @@
+var/lib/arsse
\ No newline at end of file
diff --git a/dist/debian/arsse.install b/dist/debian/arsse.install
new file mode 100644
index 00000000..b3b7ebad
--- /dev/null
+++ b/dist/debian/arsse.install
@@ -0,0 +1,18 @@
+lib usr/share/arsse/
+locale usr/share/arsse/
+sql usr/share/arsse/
+vendor usr/share/arsse/
+www usr/share/arsse/
+CHANGELOG usr/share/arsse/
+UPGRADING usr/share/arsse/
+README.md usr/share/arsse/
+arsse.php usr/share/arsse/
+
+config.defaults.php etc/arsse/
+manual usr/share/doc/arsse/
+dist/man/* usr/share/man/
+dist/debian/config.php etc/arsse/
+dist/debian/dbconfig-common.php usr/share/arsse/
+debian/bin/arsse usr/bin/
+debian/nginx etc/arsse/
+debian/apache etc/arsse/
diff --git a/dist/debian/arsse.links b/dist/debian/arsse.links
new file mode 100644
index 00000000..15663aad
--- /dev/null
+++ b/dist/debian/arsse.links
@@ -0,0 +1 @@
+etc/arsse/config.php usr/share/arsse/config.php
\ No newline at end of file
diff --git a/dist/debian/arsse.postinst b/dist/debian/arsse.postinst
new file mode 100644
index 00000000..90d4633f
--- /dev/null
+++ b/dist/debian/arsse.postinst
@@ -0,0 +1,29 @@
+#!/bin/sh
+
+set -e
+
+. /usr/share/debconf/confmodule
+
+if [ "$1" = "configure" ]; then
+ # Set permissions on configuration file
+ dpkg-statoverride --list "/etc/arsse/config.php" >/dev/null || dpkg-statoverride --update --add root www-data 640 "/etc/arsse/config.php"
+ # Set up dbconfig-common
+ if test -f /usr/share/dbconfig-common/dpkg/postinst; then
+ . /usr/share/dbconfig-common/dpkg/postinst
+ dbc_generate_include_owner="root:www-data"
+ dbc_generate_include_perms="0640"
+ dbc_generate_include="php:/var/lib/arsse/dbconfig.inc"
+ dbc_pgsql_createdb_encoding="UTF8' lc_collate='C"
+ dbc_mysql_createdb_encoding="UTF8"
+ dbc_dbfile_owner="root:www-data"
+ dbc_dbfile_perms="0660"
+ dbc_go arsse "$@"
+ fi
+fi
+
+# dh_installdeb will replace this with shell code automatically
+# generated by other debhelper scripts.
+
+#DEBHELPER#
+
+exit 0
diff --git a/dist/debian/arsse.postrm b/dist/debian/arsse.postrm
new file mode 100644
index 00000000..f9c525b3
--- /dev/null
+++ b/dist/debian/arsse.postrm
@@ -0,0 +1,20 @@
+#!/bin/sh
+
+set -e
+
+if test -f /usr/share/debconf/confmodule; then
+ . /usr/share/debconf/confmodule
+fi
+
+# Set up dbconfig-common
+if test -f /usr/share/dbconfig-common/dpkg/postrm; then
+ . /usr/share/dbconfig-common/dpkg/postrm
+ dbc_go arsse "$@"
+fi
+
+# dh_installdeb will replace this with shell code automatically
+# generated by other debhelper scripts.
+
+#DEBHELPER#
+
+exit 0
diff --git a/dist/debian/arsse.prerm b/dist/debian/arsse.prerm
new file mode 100644
index 00000000..c0cc81d7
--- /dev/null
+++ b/dist/debian/arsse.prerm
@@ -0,0 +1,16 @@
+#!/bin/sh
+
+set -e
+
+. /usr/share/debconf/confmodule
+
+# Set up dbconfig-common
+. /usr/share/dbconfig-common/dpkg/prerm
+dbc_go arsse "$@"
+
+# dh_installdeb will replace this with shell code automatically
+# generated by other debhelper scripts.
+
+#DEBHELPER#
+
+exit 0
diff --git a/dist/debian/compat b/dist/debian/compat
new file mode 100644
index 00000000..f599e28b
--- /dev/null
+++ b/dist/debian/compat
@@ -0,0 +1 @@
+10
diff --git a/dist/debian/config.php b/dist/debian/config.php
new file mode 100644
index 00000000..81f5e64f
--- /dev/null
+++ b/dist/debian/config.php
@@ -0,0 +1,15 @@
+ true,
+]
++ (@include "/usr/share/arsse/dbconfig-common.php");
diff --git a/dist/debian/control b/dist/debian/control
new file mode 100644
index 00000000..083c5080
--- /dev/null
+++ b/dist/debian/control
@@ -0,0 +1,32 @@
+Source: arsse
+Maintainer: J. King
+Section: contrib/net
+Priority: optional
+Standards-Version: 4.5.1
+Homepage: https://thearsse.com/
+Vcs-Browser: https://code.mensbeam.com/MensBeam/arsse/
+Vcs-Git: https://code.mensbeam.com/MensBeam/arsse/
+Build-Depends: debhelper
+
+Package: arsse
+Architecture: all
+Section: contrib/net
+Priority: optional
+Homepage: https://thearsse.com/
+Description: Multi-protocol RSS/Atom newsfeed synchronization server
+ The Arsse bridges the gap between multiple existing newsfeed aggregator
+ client protocols such as Tiny Tiny RSS, Nextcloud News and Miniflux,
+ allowing you to use compatible clients for many protocols with a single
+ server.
+Depends: ${misc:Depends},
+ dbconfig-sqlite3 | dbconfig-pgsql | dbconfig-mysql | dbconfig-no-thanks,
+ php (>= 7.1.0),
+ php-cli,
+ php-intl,
+ php-json,
+ php-xml,
+ php-sqlite3 | php-pgsql | php-mysql
+Recommends: nginx | apache2,
+ php-fpm,
+ php-curl,
+ ca-certificates
diff --git a/dist/debian/copyright b/dist/debian/copyright
new file mode 100644
index 00000000..a3b5afa8
--- /dev/null
+++ b/dist/debian/copyright
@@ -0,0 +1,34 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: arsse
+Upstream-Contact: J. King
+Source: https://code.mensbeam.com/MensBeam/arsse/
+License: Expat
+
+Files: *
+Copyright: 2017 J. King
+ 2017 Dustin Wilson
+License: Expat
+
+License: Expat
+ Copyright (c) 2017 J. King, Dustin Wilson
+ .
+ Permission is hereby granted, free of charge, to any person
+ obtaining a copy of this software and associated documentation
+ files (the "Software"), to deal in the Software without
+ restriction, including without limitation the rights to use,
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the
+ Software is furnished to do so, subject to the following
+ conditions:
+ .
+ The above copyright notice and this permission notice shall be
+ included in all copies or substantial portions of the Software.
+ .
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ OTHER DEALINGS IN THE SOFTWARE.
diff --git a/dist/debian/dbconfig-common.php b/dist/debian/dbconfig-common.php
new file mode 100644
index 00000000..ee8b6745
--- /dev/null
+++ b/dist/debian/dbconfig-common.php
@@ -0,0 +1,44 @@
+ "sqlite3"];
+ if (strlen((string) $basepath) && strlen((string) $dbname)) {
+ $conf['dbSQLite3File'] = "$basepath/$dbname";
+ }
+ } elseif ($dbtype === "pgsql") {
+ $conf = [
+ 'dbDriver' => "postgresql",
+ 'dbPostgreSQLHost' => $dbserver ?? "",
+ 'dbPostgreSQLUser' => $dbuser ?? "arsse",
+ 'dbPostgreSQLPass' => $dbpass ?? "",
+ 'dbPostgreSQLPort' => (int) $dbport ?: 5432,
+ 'dbPostgreSQLDb' => $dbname ?? "arsse",
+ ];
+ } elseif ($dbtype === "mysql") {
+ $conf = [
+ 'dbDriver' => "mysql",
+ 'dbMySQLHost' => $dbserver ?? "",
+ 'dbMySQLUser' => $dbuser ?? "arsse",
+ 'dbMySQLPass' => $dbpass ?? "",
+ 'dbMySQLPort' => (int) $dbport ?: 3306,
+ 'dbMySQLDb' => $dbname ?? "arsse",
+ ];
+ } else {
+ throw new \Exception("Debian dbconfig-common configuration file $dbconfpath is invalid");
+ }
+ return $conf;
+} else {
+ // if no configuration file exists simply return an empty array
+ return [];
+}
diff --git a/dist/debian/lintian-overrides b/dist/debian/lintian-overrides
new file mode 100644
index 00000000..1847d9af
--- /dev/null
+++ b/dist/debian/lintian-overrides
@@ -0,0 +1,6 @@
+# We make reference to "Tiny Tiny RSS"
+spelling-error-in-description Tiny Tiny (duplicate word) Tiny
+# The manual for DrUUID (a dependency) includes a harmless "up" link
+privacy-breach-generic usr/share/arsse/vendor/jkingweb/druuid/documentation/manual.html [ ] (http://jkingweb.ca/code/)
+# We only ask dbconfig-common questions, which don't seem to require templates
+no-debconf-templates
diff --git a/dist/debian/pbuilder.sh b/dist/debian/pbuilder.sh
new file mode 100755
index 00000000..e7f0468c
--- /dev/null
+++ b/dist/debian/pbuilder.sh
@@ -0,0 +1,40 @@
+#! /bin/bash -e
+
+###
+# This script is fed to pbuilder to build Debian packages. The base tarball
+# should be created with a command similar to the following:
+#
+# sudo pbuilder create --basetgz pbuilder-arsse.tgz --mirror http://ftp.ca.debian.org/debian/ --extrapackages "debhelper devscripts lintian"
+#
+# Thereafter pbuilder can be used to build packages with this command:
+#
+# sudo pbuilder execute --basetgz pbuilder-arsse.tgz --bindmounts `basedir "/path/to/release/tarball"` -- pbuilder.sh "/path/to/release/tarball"
+#
+# This somewhat roundabout procedure is used because the pbuilder debuild
+# command does not seem to work in Arch Linux, nor does pdebuild. Doing
+# as much as possible within the chroot itself works around these problems.
+###
+
+# create a temporary directory
+tmp=`mktemp -d`
+
+# define various variables
+here=`dirname "$1"`
+tarball=`basename "$1"`
+version=`echo "$tarball" | grep -oP '\d+(?:\.\d+)*' | head -1`
+out="$here/debian"
+in="$tmp/arsse-$version"
+
+# create necessary directories
+mkdir -p "$in" "$out"
+# extract the release tarball
+tar -C "$in" -xf "$1" --strip-components=1
+# repackage the release tarball into a Debian "orig" tarball
+tar -C "$tmp" -czf "$tmp/arsse_$version.orig.tar.gz" "arsse-$version"
+# copy the "dist/debian" directory down the tree where Debian expects it
+cp -r "$in/dist/debian" "$in/debian"
+# build the package
+cd "$in"
+debuild -us -uc
+# move the resultant files to their final destination
+find "$tmp" -maxdepth 1 -type f -exec mv '{}' "$out" \;
diff --git a/dist/debian/rules b/dist/debian/rules
new file mode 100755
index 00000000..18a2ae70
--- /dev/null
+++ b/dist/debian/rules
@@ -0,0 +1,26 @@
+#!/usr/bin/make -f
+
+DH_VERBOSE = 1
+
+%:
+ dh $@
+
+execute_before_dh_install:
+ # Adapt the systemd service for Debian: this involves using only the "arsse-fetch" unit (renamed to "arsse"), removing the "PartOf" directive, and changing the user and group to "www-data"
+ cp dist/systemd/arsse-fetch.service debian/arsse.service
+ sed -i -se 's/^PartOf=.*//' debian/arsse.service
+ sed -i -se 's/^\(User\|Group\)=.*/\1=www-data/' debian/arsse.service
+ # Adapt the init script for Debian: this involves changing the user and group to "www-data"
+ cp dist/init.sh debian/arsse.init
+ sed -i -se 's/^\([ \t]*chown\) arsse:arsse /\1 www-data:www-data /' debian/arsse.init
+ # Change the user and group references in tmpfiles
+ cp dist/tmpfiles.conf debian/arsse.tmpfiles
+ sed -i -se 's/ arsse / www-data /' debian/arsse.tmpfiles
+ sed -i -se 's/ arsse / www-data /' debian/arsse.tmpfiles
+ # Change the user reference in the executable file
+ mkdir -p debian/bin
+ cp dist/arsse debian/bin/arsse
+ sed -i -se 's/posix_getpwnam("arsse"/posix_getpwnam("www-data"/' debian/bin/arsse
+ # Change PHP-FPM socket paths
+ cp -r dist/apache dist/nginx debian
+ sed -i -se 's/arsse\.sock/php-fpm.sock/' debian/apache/arsse.conf debian/nginx/arsse.conf
diff --git a/dist/debian/source/format b/dist/debian/source/format
new file mode 100644
index 00000000..163aaf8d
--- /dev/null
+++ b/dist/debian/source/format
@@ -0,0 +1 @@
+3.0 (quilt)
diff --git a/dist/debian/source/lintian-overrides b/dist/debian/source/lintian-overrides
new file mode 100644
index 00000000..ab33538b
--- /dev/null
+++ b/dist/debian/source/lintian-overrides
@@ -0,0 +1,2 @@
+# Development environment is slightly out of date
+newer-standards-version
diff --git a/dist/init.sh b/dist/init.sh
new file mode 100644
index 00000000..861d1279
--- /dev/null
+++ b/dist/init.sh
@@ -0,0 +1,78 @@
+#!/bin/sh
+
+### BEGIN INIT INFO
+# Provides: arsse
+# Required-Start: $local_fs $network
+# Required-Stop: $local_fs postgresql mysql
+# Should-Start: postgresql mysql
+# Should-Stop: postgresql mysql
+# Default-Start: 2 3 4 5
+# Default-Stop: 0 1 6
+# Short-Description: The Advanced RSS Environment
+# Description: The Arsse is a multi-protocol Web newsfeed synchronization service
+### END INIT INFO
+
+# This script is designed for Debian; some adaptation will be required for other systems
+
+PATH=/usr/sbin/:/usr/bin:/sbin:/bin
+NAME=arsse
+DESC="newsfeed synchronization server"
+PIDFILE=/run/arsse.pid
+DAEMON=/usr/bin/$NAME
+
+. /lib/init/vars.sh
+. /lib/lsb/init-functions
+
+arsse_start() {
+ touch "$PIDFILE"
+ chown arsse:arsse "$PIDFILE"
+ $DAEMON daemon --fork="$PIDFILE" || return 2
+}
+
+arsse_stop() {
+ killproc -p "$PIDFILE" "$DAEMON"
+}
+
+arsse_reload() {
+ killproc -p "$PIDFILE" "$DAEMON" HUP
+}
+
+case "$1" in
+ start)
+ log_daemon_msg "Starting $DESC" "$NAME"
+ if pidofproc -p $PIDFILE "$DAEMON" > /dev/null 2>&1 ; then
+ return 1
+ fi
+ arsse_start
+ ;;
+ stop)
+ log_daemon_msg "Stopping $DESC" "$NAME"
+ arsse_stop
+ ;;
+ restart)
+ log_daemon_msg "Restarting $DESC" "$NAME"
+ if pidofproc -p $PIDFILE "$DAEMON" > /dev/null 2>&1 ; then
+ arsse_stop
+ fi
+ arsse_start
+ ;;
+ try-restart)
+ if pidofproc -p $PIDFILE "$DAEMON" > /dev/null 2>&1 ; then
+ log_daemon_msg "Restarting $DESC" "$NAME"
+ arsse_stop
+ arsse_start
+ fi
+ ;;
+ reload|force-reload)
+ log_daemon_msg "Reloading $DESC" "$NAME"
+ arsse_reload
+ ;;
+ status)
+ status_of_proc -p $PIDFILE $DAEMON $NAME
+ exit $?
+ ;;
+ *)
+ echo "Usage: $0 {start|stop|restart|try-restart|reload|status}" >&2
+ exit 3
+ ;;
+esac
diff --git a/dist/nginx.conf b/dist/nginx.conf
deleted file mode 100644
index c9c7845b..00000000
--- a/dist/nginx.conf
+++ /dev/null
@@ -1,58 +0,0 @@
-server {
- server_name example.com;
- listen 80; # adding HTTPS configuration is highly recommended
- root /usr/share/arsse/www; # adjust according to your installation path
-
- location / {
- try_files $uri $uri/ =404;
- }
-
- location @arsse {
- fastcgi_pass unix:/var/run/php/php7.2-fpm.sock; # adjust according to your system configuration
- fastcgi_pass_header Authorization; # required if the Arsse is to perform its own HTTP authentication
- fastcgi_pass_request_body on;
- fastcgi_pass_request_headers on;
- fastcgi_intercept_errors off;
- fastcgi_buffering off;
- fastcgi_param SCRIPT_FILENAME /usr/share/arsse/arsse.php; # adjust according to your installation path
- fastcgi_param REQUEST_METHOD $request_method;
- fastcgi_param CONTENT_TYPE $content_type;
- fastcgi_param CONTENT_LENGTH $content_length;
- fastcgi_param REQUEST_URI $uri;
- fastcgi_param QUERY_STRING $query_string;
- fastcgi_param HTTPS $https if_not_empty;
- fastcgi_param REMOTE_USER $remote_user;
- }
-
- # Nextcloud News protocol
- location /index.php/apps/news/api {
- try_files $uri @arsse;
-
- location ~ ^/index\.php/apps/news/api/?$ {
- # this path should not be behind HTTP authentication
- try_files $uri @arsse;
- }
- }
-
- # Tiny Tiny RSS protocol
- location /tt-rss/api {
- try_files $uri @arsse;
- }
-
- # Tiny Tiny RSS feed icons
- location /tt-rss/feed-icons/ {
- try_files $uri @arsse;
- }
-
- # Tiny Tiny RSS special-feed icons; these are static files
- location /tt-rss/images/ {
- # this path should not be behind HTTP authentication
- try_files $uri =404;
- }
-
- # Fever protocol
- location /fever/ {
- # this path should not be behind HTTP authentication
- try_files $uri @arsse;
- }
-}
diff --git a/dist/nginx/arsse-fcgi.conf b/dist/nginx/arsse-fcgi.conf
new file mode 100644
index 00000000..eb83097f
--- /dev/null
+++ b/dist/nginx/arsse-fcgi.conf
@@ -0,0 +1,12 @@
+fastcgi_pass_header Authorization; # required if the Arsse is to perform its own HTTP authentication
+fastcgi_pass_request_body on;
+fastcgi_pass_request_headers on;
+fastcgi_intercept_errors off;
+fastcgi_buffering off;
+fastcgi_param REQUEST_METHOD $request_method;
+fastcgi_param CONTENT_TYPE $content_type;
+fastcgi_param CONTENT_LENGTH $content_length;
+fastcgi_param REQUEST_URI $uri;
+fastcgi_param QUERY_STRING $query_string;
+fastcgi_param HTTPS $https if_not_empty;
+fastcgi_param REMOTE_USER $remote_user;
diff --git a/dist/nginx/arsse-loc.conf b/dist/nginx/arsse-loc.conf
new file mode 100644
index 00000000..d7e3ec78
--- /dev/null
+++ b/dist/nginx/arsse-loc.conf
@@ -0,0 +1,49 @@
+# Any provided static files
+location / {
+ try_files $uri $uri/ =404;
+}
+
+# Nextcloud News protocol
+location /index.php/apps/news/api {
+ try_files $uri @arsse;
+
+ location ~ ^/index\.php/apps/news/api/?$ {
+ try_files $uri @arsse_public;
+ }
+}
+
+# Tiny Tiny RSS protocol
+location /tt-rss/api {
+ try_files $uri @arsse;
+}
+
+# Tiny Tiny RSS feed icons
+location /tt-rss/feed-icons/ {
+ try_files $uri @arsse;
+}
+
+# Tiny Tiny RSS special-feed icons; these are static files
+location /tt-rss/images/ {
+ try_files $uri =404;
+}
+
+# Fever protocol
+location /fever/ {
+ try_files $uri @arsse;
+}
+
+# Miniflux protocol
+location /v1/ {
+ # If put behind HTTP authentication token login will not be possible
+ try_files $uri @arsse;
+}
+
+# Miniflux version number
+location /version {
+ try_files $uri @arsse_public;
+}
+
+# Miniflux "health check"
+location /healthcheck {
+ try_files $uri @arsse_public;
+}
diff --git a/dist/nginx/arsse.conf b/dist/nginx/arsse.conf
new file mode 100644
index 00000000..fe5721e8
--- /dev/null
+++ b/dist/nginx/arsse.conf
@@ -0,0 +1,17 @@
+root /usr/share/arsse/www;
+
+location @arsse {
+ # HTTP authentication may be enabled for this location, though this may impact some features
+ fastcgi_pass unix:/var/run/php/arsse.sock;
+ fastcgi_param SCRIPT_FILENAME /usr/share/arsse/arsse.php;
+ include /etc/arsse/nginx/arsse-fcgi.conf;
+}
+
+location @arsse_public {
+ # HTTP authentication should not be enabled for this location
+ fastcgi_pass unix:/var/run/php/arsse.sock;
+ fastcgi_param SCRIPT_FILENAME /usr/share/arsse/arsse.php;
+ include /etc/arsse/nginx/arsse-fcgi.conf;
+}
+
+include /etc/arsse/nginx/arsse-loc.conf;
diff --git a/dist/nginx/example.conf b/dist/nginx/example.conf
new file mode 100644
index 00000000..571a6385
--- /dev/null
+++ b/dist/nginx/example.conf
@@ -0,0 +1,13 @@
+server {
+ server_name news.example.com;
+ listen 80;
+ listen [::]:80;
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+
+ ssl_certificate /etc/letsencrypt/live/news.example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/news.example.com/privkey.pem;
+ ssl_trusted_certificate /etc/letsencrypt/live/news.example.com/chain.pem;
+
+ include /etc/arsse/nginx/arsse.conf;
+}
diff --git a/dist/php-fpm.conf b/dist/php-fpm.conf
new file mode 100644
index 00000000..f1edc41c
--- /dev/null
+++ b/dist/php-fpm.conf
@@ -0,0 +1,11 @@
+[arsse]
+user = arsse
+group = arsse
+listen = /var/run/php/arsse.sock
+listen.owner = arsse
+listen.group = www-data
+pm = dynamic
+pm.max_children = 5
+pm.start_servers = 2
+pm.min_spare_servers = 1
+pm.max_spare_servers = 3
diff --git a/dist/systemd/arsse-fetch.service b/dist/systemd/arsse-fetch.service
new file mode 100644
index 00000000..3ae35538
--- /dev/null
+++ b/dist/systemd/arsse-fetch.service
@@ -0,0 +1,36 @@
+[Unit]
+Description=The Arsse newsfeed fetching service
+Documentation=https://thearsse.com/manual/
+PartOf=arsse.service
+
+[Install]
+WantedBy=multi-user.target
+
+[Service]
+User=arsse
+Group=arsse
+Type=simple
+WorkingDirectory=/usr/share/arsse
+ExecStart=/usr/bin/arsse daemon
+
+ProtectProc=invisible
+NoNewPrivileges=true
+ProtectSystem=full
+ProtectHome=true
+StateDirectory=arsse
+ConfigurationDirectory=arsse
+PrivateTmp=true
+PrivateDevices=true
+RestrictSUIDSGID=true
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=arsse
+Restart=on-failure
+RestartPreventStatus=
+
+# These directives can be used for extra security, but are disabled for now for compatibility
+
+#ReadOnlyPaths=/
+#ReadWriePaths=/var/lib/arsse
+#NoExecPaths=/
+#ExecPaths=/usr/bin/php
diff --git a/dist/systemd/arsse.service b/dist/systemd/arsse.service
new file mode 100644
index 00000000..99da8585
--- /dev/null
+++ b/dist/systemd/arsse.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=The Arsse newsfeed management service
+Documentation=https://thearsse.com/manual/
+Requires=arsse-fetch.service
+BindsTo=php-fpm.service
+After=php-fpm.service
+
+[Install]
+WantedBy=multi-user.target
+
+[Service]
+Type=oneshot
+RemainAfterExit=true
+ExecStart=/usr/bin/true
diff --git a/dist/sysuser.conf b/dist/sysuser.conf
new file mode 100644
index 00000000..cd708c3f
--- /dev/null
+++ b/dist/sysuser.conf
@@ -0,0 +1 @@
+u arsse - "The Arsse" /var/lib/arsse -
diff --git a/dist/tmpfiles.conf b/dist/tmpfiles.conf
new file mode 100644
index 00000000..f5e6ed18
--- /dev/null
+++ b/dist/tmpfiles.conf
@@ -0,0 +1,4 @@
+z /usr/bin/arsse 0755 root arsse - -
+z /etc/arsse/config.php 0640 root arsse - -
+L /usr/share/arsse/config.php - root arsse - /etc/arsse/config.php
+d /var/lib/arsse 0750 arsse arsse - -
diff --git a/docs/en/010_About.md b/docs/en/010_About.md
index 615185f4..3ffc49bb 100644
--- a/docs/en/010_About.md
+++ b/docs/en/010_About.md
@@ -1,5 +1,6 @@
The Advanced RSS Environment (affectionately called "The Arsse") is a news aggregator server which implements multiple synchronization protocols. Unlike most other aggregator servers, The Arsse does not include a Web front-end (though one is planned as a separate project), and it relies on [existing protocols](Supported_Protocols) to maximize compatibility with [existing clients](Compatible_Clients). Supported protocols are:
+- Miniflux
- Nextcloud News
- Tiny Tiny RSS
- Fever
diff --git a/docs/en/020_Getting_Started/010_Requirements.md b/docs/en/020_Getting_Started/010_Requirements.md
deleted file mode 100644
index 7b3b6ef1..00000000
--- a/docs/en/020_Getting_Started/010_Requirements.md
+++ /dev/null
@@ -1,16 +0,0 @@
-The Arsse has the following requirements:
-
-- A Linux server running Nginx or Apache 2.4
-- PHP 7.1.0 or later with the following extensions:
- - [intl](http://php.net/manual/en/book.intl.php), [json](http://php.net/manual/en/book.json.php), [hash](http://php.net/manual/en/book.hash.php), and [dom](http://php.net/manual/en/book.dom.php)
- - [simplexml](http://php.net/manual/en/book.simplexml.php), and [iconv](http://php.net/manual/en/book.iconv.php)
- - One of:
- - [sqlite3](http://php.net/manual/en/book.sqlite3.php) or [pdo_sqlite](http://php.net/manual/en/ref.pdo-sqlite.php) for SQLite databases
- - [pgsql](http://php.net/manual/en/book.pgsql.php) or [pdo_pgsql](http://php.net/manual/en/ref.pdo-pgsql.php) for PostgreSQL 10 or later databases
- - [mysqli](http://php.net/manual/en/book.mysqli.php) or [pdo_mysql](http://php.net/manual/en/ref.pdo-mysql.php) for MySQL/Percona 8.0.11 or later databases
- - [curl](http://php.net/manual/en/book.curl.php) (optional)
-- Privileges either to create and run systemd services, or to run cron jobs
-
-Instructions for how to satisfy the PHP extension requirements for Debian systems are included in the next section.
-
-It is also be possible to run The Arsse on other operating systems (including Windows) and with other Web servers, but the configuration required to do so is not documented in this manual.
diff --git a/docs/en/020_Getting_Started/020_Download_and_Installation.md b/docs/en/020_Getting_Started/020_Download_and_Installation.md
deleted file mode 100644
index ed47a7d1..00000000
--- a/docs/en/020_Getting_Started/020_Download_and_Installation.md
+++ /dev/null
@@ -1,41 +0,0 @@
-[TOC]
-
-# Downloading The Arse
-
-The latest version of The Arsse can be downloaded [from our Web site](https://thearsse.com/). If installing an older release from our archives, the attachments named _arsse-x.x.x.tar.gz_ should be used rather than those marked "Source Code".
-
-Installation from source code is also possible, but the release packages are recommended.
-
-# Installation
-
-In order for The Arsse to function correctly, [its requirements](Requirements) must first be satisfied. The process of installing the required PHP extensions differs from one system to the next, but on Debian the following series of commands should do:
-
-```sh
-# Install PHP; this assumes the FastCGI process manager will be used
-sudo apt install php-cli php-fpm
-# Install the needed PHP extensions; php-curl is optional
-sudo apt install php-intl php-json php-xml php-curl
-# Install any one of the required database extensions
-sudo apt install php-sqlite3 php-pgsql php-mysql
-```
-
-Then, it's a simple matter of unpacking the archive someplace (`/usr/share/arsse` is the recommended location on Debian systems, but it can be anywhere) and setting permissions:
-
-```sh
-# Unpack the archive
-sudo tar -xzf arsse-x.x.x.tar.gz -C "/usr/share"
-# Make the user running the Web server the owner of the files
-sudo chown -R www-data:www-data "/usr/share/arsse"
-# Ensure the owner can create files such as the SQLite database
-sudo chmod o+rwX "/usr/share/arsse"
-```
-
-# Next steps
-
-If using a database other than SQLite, you will likely want to [set it up](Database_Setup) before doing anything else.
-
-In order for the various synchronization protocols to work, a Web server [must be configured](Web_Server_Configuration), and in order for The Arsse to serve users, those users [must be created](/en/Using_The_Arsse/Managing_Users).
-
-You may also want to review the `config.defaults.php` file included in the download package and create [a configuration file](Configuration), though The Arsse can function even without using a configuration file.
-
-Finally, The Arsse's [newsfeed refreshing service](/en/Using_The_Arsse/Keeping_Newsfeeds_Up_to_Date) needs to be installed in order for news to actually be fetched from the Internet.
diff --git a/docs/en/020_Getting_Started/020_Download_and_Installation/010_On_Arch_Linux.md b/docs/en/020_Getting_Started/020_Download_and_Installation/010_On_Arch_Linux.md
new file mode 100644
index 00000000..e075c1b2
--- /dev/null
+++ b/docs/en/020_Getting_Started/020_Download_and_Installation/010_On_Arch_Linux.md
@@ -0,0 +1,54 @@
+[TOC]
+
+# Downloading The Arsse
+
+Since version 0.9.2 The Arsse is available from the [Arch User Repository](https://aur.archlinux.org/) as packages `arsse` and `arsse-git`. The latter should normally only be used to test bug fixes.
+
+Generic release tarballs may also be downloaded [from our Web site](https://thearsse.com), and the `PKGBUILD` file (found under `arsse/dist/arch/`) can then be extracted alongside the tarball and used to build the `arsse` package. Installing directly from the generic release tarball without producing an Arch package is not recommended as the package-building process performs various adjustments to handle Arch peculiarities.
+
+# Installation
+
+For illustrative purposes, this document assumes the `yay` [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) will be used to download, build, and install The Arsse. This section summarises the steps necessary to configure and use The Arsse after installtion:
+
+```sh
+# Install the package
+sudo yay -S arsse
+# Enable the necessary PHP extensions; curl is optional but recommended; pdo_sqlite may be used instead of sqlite3, but this is not recommended
+sudo sed -i -e 's/^;\(extension=\(curl\|iconv\|intl\|sqlite3\)\)$/\1/' /etc/php/php.ini
+# Enable and start the necessary systemd units
+sudo systemctl enable php-fpm arsse
+sudo systemctl restart php-fpm arsse
+```
+
+Note that the above is the most concise process, not necessarily the recommended one. In particular [it is recommended](https://wiki.archlinux.org/title/PHP#Extensions) to use `/etc/php/conf.d/` to enable PHP extensions rather than editing `php.ini` as done above.
+
+# Web server configuration
+
+Sample configuration for both Nginx and Apache HTTP Server can be found in `/etc/webapps/arsse/nginx/` and `/etc/webapps/arsse/apache/`, respectively. The `example.conf` files are basic virtual host examples; the other files they include should normally be usable without modification, but may be modified if desired.
+
+If using Apache HTTP Server the `mod_proxy` and `mod_proxy_fcgi` modules must be enabled. This can be achieved by adding the following lines to your virtual host or global configuration:
+
+```apache
+LoadModule proxy_module modules/mod_proxy.so
+LoadModule proxy_fcgi_module modules/mod_proxy_fcgi.so
+```
+
+No additional set-up is required for Nginx.
+
+# Next steps
+
+If using a database other than SQLite, you will likely want to [set it up](/en/Getting_Started/Database_Setup) before doing anything else.
+
+In order for The Arsse to serve users, those users [must be created](/en/Using_The_Arsse/Managing_Users).
+
+You may also want to review the `config.defaults.php` file included in `/etc/webapps/arsse/` or consult [the documentation for the configuration file](/en/Getting_Started/Configuration), though The Arsse should function with the default configuration.
+
+# Upgrading
+
+Upgrading The Arsse is done like any other package. By default The Arsse will perform any required database schema upgrades when the new version is executed, so the service does need to be restarted:
+
+```sh
+sudo systemctl restart arsse
+```
+
+Occasionally changes to Web server configuration have been required, such as when new protocols become supported; these changes are always explicit in the `UPGRADING` file.
diff --git a/docs/en/020_Getting_Started/020_Download_and_Installation/020_On_Debian_and_Derivatives.md b/docs/en/020_Getting_Started/020_Download_and_Installation/020_On_Debian_and_Derivatives.md
new file mode 100644
index 00000000..891bdfbc
--- /dev/null
+++ b/docs/en/020_Getting_Started/020_Download_and_Installation/020_On_Debian_and_Derivatives.md
@@ -0,0 +1,72 @@
+[TOC]
+
+# Downloading The Arsse
+
+Since version 0.10.0 pre-built Debian packages for The Arsse are available from the [OpenSUSE Build Service](https://build.opensuse.org/) (OBS) under [the author's personal project repository](https://build.opensuse.org/package/show/home:JKingWeb/arsse). This is the preferred method for instaling the software and is the means documented below.
+
+Generic release tarballs may also be downloaded [from our Web site](https://thearsse.com), and a Debian package built manually. Installing directly from the generic release tarball without producing a Debian package is not recommended as the Debian packages make the set-up process on Debian systems significantly simpler.
+
+# Adding the repository
+
+In order to install The Arsse, the OBS repository must first be configured along with its signing key:
+
+```sh
+# Add the key
+wget -q -O - "https://download.opensuse.org/repositories/home:/JKingWeb/Debian_Unstable/Release.key" | gpg --dearmor | sudo tee "/usr/share/keyrings/arsse-obs-keyring.gpg" >/dev/null
+# Add the repository
+echo "deb [signed-by=/usr/share/keyrings/arsse-obs-keyring.gpg] https://download.opensuse.org/repositories/home:/JKingWeb/Debian_Unstable/ ./" | sudo tee "/etc/apt/sources.list.d/arsse-obs.list" >/dev/null
+# Update APT's database
+sudo apt update -qq
+```
+
+Please note that the "Unstable" qualifier in the repository URL is a reference to Debian's "sid" release and is not a reflection on The Arsse's stability. The repository should be suitable for any Debian version or derivative which includes a sufficiently recent version of PHP.
+
+# Installation
+
+Once the OBS repository is configured, installing The Arsse is achieved with a single command:
+
+```sh
+sudo apt install arsse
+```
+
+After installation is complete The Arsse will be started automatically.
+
+During the installation process you will be prompted whether to allow `dbconfig-common` to configure The Arsse's database automatically. The default `sqlite3` (SQLite) option is a good choice, but `pgsql` (PostgreSQL) and `mysql` (MySQL) are possible alternatives. If you wish to [use a database other than SQLite](/en/Getting_Started/Database_Setup/index), you should install it before installing The Arsse:
+
+```sh
+# Install PostgreSQL
+sudo apt install postgresql php-pgsql
+# Install MySQL
+sudo apt install mysql-server php-mysql
+# Install SQLite explicitly
+sudo apt install php-sqlite3
+```
+
+If you wish to change the database backend after having installed The Arsse, running `dpkg-reconfigure` after installing the database server can be used to achieve this:
+
+```sh
+sudo dpkg-reconfigure arsse
+```
+
+# Web server configuration
+
+Sample configuration for both Nginx and Apache HTTP Server can be found in `/etc/arsse/nginx/` and `/etc/arsse/apache/`, respectively. The `example.conf` files are basic virtual host examples; the other files they include should normally be usable without modification, but may be modified if needed or desired. In particularly users of systems older than Debian 11 (Bullseye) or Ubuntu 20.04 (Focal Fossa) or derivatives will need to change the PHP-FPM socket path in Nginx or Apache's `arsse.conf`.
+
+In order to use Apache HTTP Server the FastCGI proxy module must be enabled and the server restarted:
+
+```sh
+sudo a2enmod proxy proxy_fcgi
+sudo systemctl restart apache2
+```
+
+No additional set-up is required for Nginx.
+
+# Next steps
+
+In order for The Arsse to serve users, those users [must be created](/en/Using_The_Arsse/Managing_Users).
+
+You may also want to review the `config.defaults.php` file included in the download package and create [a configuration file](/en/Getting_Started/Configuration), though The Arsse can function even without using a configuration file.
+
+# Upgrading
+
+Upgrading The Arsse is done like any other package. Occasionally changes to Web server configuration have been required, such as when new protocols become supported; these changes are always explicit in the `UPGRADING` file.
diff --git a/docs/en/020_Getting_Started/020_Download_and_Installation/999_ On_Other_Systems.md b/docs/en/020_Getting_Started/020_Download_and_Installation/999_ On_Other_Systems.md
new file mode 100644
index 00000000..a496aa04
--- /dev/null
+++ b/docs/en/020_Getting_Started/020_Download_and_Installation/999_ On_Other_Systems.md
@@ -0,0 +1,53 @@
+# Downloading The Arsse
+
+The Arsse should run on any operating system for which PHP and a Web server are available, but only the combination of Linux, Systemd, Nginx, and PHP-FPM has been extensively tested.
+
+Below are very generic instructions and suggestions for installing The Arsse on systems for which pre-built packages are not available.
+
+# Requirements
+
+The Arsse has the following requirements:
+
+- A Web server such as:
+ - [Nginx](https://nginx.org)
+ - [Apache HTTP server](https://httpd.apache.org) 2.4 or later
+- PHP 7.1.0 or later with the following extensions:
+ - [intl](https://php.net/manual/en/book.intl.php), [json](https://php.net/manual/en/book.json.php), [hash](https://php.net/manual/en/book.hash.php), [filter](https://php.net/manual/en/book.filter.php), and [dom](https://php.net/manual/en/book.dom.php)
+ - [simplexml](https://php.net/manual/en/book.simplexml.php), and [iconv](https://php.net/manual/en/book.iconv.php)
+ - One of:
+ - [sqlite3](https://php.net/manual/en/book.sqlite3.php) or [pdo_sqlite](https://php.net/manual/en/ref.pdo-sqlite.php) for SQLite databases
+ - [pgsql](https://php.net/manual/en/book.pgsql.php) or [pdo_pgsql](https://php.net/manual/en/ref.pdo-pgsql.php) for PostgreSQL 10 or later databases
+ - [mysqli](https://php.net/manual/en/book.mysqli.php) or [pdo_mysql](https://php.net/manual/en/ref.pdo-mysql.php) for MySQL/Percona 8.0.11 or later databases
+ - [curl](https://php.net/manual/en/book.curl.php) (optional)
+ - [posix](https://php.net/manual/en/book.posix.php) and [pcntl](https://php.net/manual/en/book.pcntl.php) (both optional)
+- An interface between PHP and the Web server, such as [PHP-FPM](https://php.net/manual/en/install.fpm.php)
+- Privileges either to create and run system services, or to run cron jobs
+
+# Installation
+
+1. Download [the latest release](https://thearsse.com/releases/current) and extract it somewhere, such as `/usr/share/arsse/`
+2. [Set up your database](/en/Getting_Started/Database_Setup)
+3. Create [a configuration file](/en/Getting_Started/Configuration) if needed
+4. Consult the files under `dist/nginx` and `dist/apache` for sample Web server configuration
+5. Consult `dist/arsse` for a sample executable script which drops privileges on POSIX systems
+6. Start the newsfeed fetching service:
+ - Sample Systemd service files are available under `dist/systemd`
+ - A sample System V init script is available in `dist/init.sh`
+ - A persistent process can be started by running `php arsse.php daemon`
+ - It is also possible [to use cron](/en/Using_The_Arsse/Other_Topics.html#page_Refreshing_newsfeeds_with_a_cron_job) or a similar task-scheduling tool
+7. [Create users](/en/Using_The_Arsse/Managing_Users) to grant them access
+
+# Upgrading
+
+Upgrading The Arsse is usually simple:
+
+1. Download the latest release
+2. Check the `UPGRADING` file for any special notes
+3. Stop the newsfeed refreshing service if it is running
+4. Back up your configurationm and database
+5. Extract the new version on top of the old one
+6. Restart the newsfeed refreshing service
+
+By default The Arsse will perform any required database schema upgrades when the new version is executed.
+
+Occasionally changes to Web server configuration have been required, when new protocols become supported; such changes are always explicit in the `UPGRADING` file
diff --git a/docs/en/020_Getting_Started/020_Download_and_Installation/index.md b/docs/en/020_Getting_Started/020_Download_and_Installation/index.md
new file mode 100644
index 00000000..4ad7b147
--- /dev/null
+++ b/docs/en/020_Getting_Started/020_Download_and_Installation/index.md
@@ -0,0 +1,16 @@
+# Installing from a package manager
+
+We currently provide a few pre-built installation packages for the following operating systems:
+
+- [Arch Linux and derivatives](On_Arch_Linux)
+- [Debian and derivatives](On_Debian_and_Derivatives)
+
+These packages significantly simplify installation, though a bit of manual effort may still be required. Updating The Arsse using these packages should require no manual intervention.
+
+# Installing manually
+
+For other systems The Arsse must currently be installed manually. As each operating system is different, we can only provide very general instructions:
+
+- [Installing on other systems](On_Other_Systems)
+
+We hope to support more operating systems in the future.
diff --git a/docs/en/020_Getting_Started/030_Web_Server_Configuration.md b/docs/en/020_Getting_Started/030_Web_Server_Configuration.md
deleted file mode 100644
index 679b0395..00000000
--- a/docs/en/020_Getting_Started/030_Web_Server_Configuration.md
+++ /dev/null
@@ -1,107 +0,0 @@
-[TOC]
-
-# Preface
-
-As a PHP application, The Arsse requires the aid of a Web server in order to communicate with clients. How to install and configure a Web server in general is outside the scope of this document, but this section provides examples and advice for Web server configuration specific to The Arsse. Any server capable of interfacing with PHP should work, though we have only tested Nginx and Apache 2.4.
-
-Samples included here only cover the bare minimum for configuring a virtual host. In particular, configuration for HTTPS (which is highly recommended) is omitted for the sake of clarity
-
-# Configuration for Nginx
-
-```nginx
-server {
- server_name example.com;
- listen 80; # adding HTTPS configuration is highly recommended
- root /usr/share/arsse/www; # adjust according to your installation path
-
- location / {
- try_files $uri $uri/ =404;
- }
-
- location @arsse {
- fastcgi_pass unix:/var/run/php/php7.2-fpm.sock; # adjust according to your system configuration
- fastcgi_pass_header Authorization; # required if the Arsse is to perform its own HTTP authentication
- fastcgi_pass_request_body on;
- fastcgi_pass_request_headers on;
- fastcgi_intercept_errors off;
- fastcgi_buffering off;
- fastcgi_param SCRIPT_FILENAME /usr/share/arsse/arsse.php; # adjust according to your installation path
- fastcgi_param REQUEST_METHOD $request_method;
- fastcgi_param CONTENT_TYPE $content_type;
- fastcgi_param CONTENT_LENGTH $content_length;
- fastcgi_param REQUEST_URI $uri;
- fastcgi_param QUERY_STRING $query_string;
- fastcgi_param HTTPS $https if_not_empty;
- fastcgi_param REMOTE_USER $remote_user;
- }
-
- # Nextcloud News protocol
- location /index.php/apps/news/api {
- try_files $uri @arsse;
-
- location ~ ^/index\.php/apps/news/api/?$ {
- # this path should not be behind HTTP authentication
- try_files $uri @arsse;
- }
- }
-
- # Tiny Tiny RSS protocol
- location /tt-rss/api {
- try_files $uri @arsse;
- }
-
- # Tiny Tiny RSS feed icons
- location /tt-rss/feed-icons/ {
- try_files $uri @arsse;
- }
-
- # Tiny Tiny RSS special-feed icons; these are static files
- location /tt-rss/images/ {
- # this path should not be behind HTTP authentication
- try_files $uri =404;
- }
-
- # Fever protocol
- location /fever/ {
- # this path should not be behind HTTP authentication
- try_files $uri @arsse;
- }
-}
-```
-
-# Configuration for Apache2
-
-There are many ways for Apache to interface with PHP, but the recommended way today is to use `mod_proxy` and `mod_proxy_fcgi` to communicate with PHP-FPM. If necessary you can enable these modules on Debian systems using the following commands:
-
-```sh
-sudo a2enmod proxy proxy_fcgi
-sudo systemctl restart apache2
-```
-
-Afterward the follow virtual host configuration should work, after modifying path as appropriate:
-
-```apache
-# N.B. the unix:/var/run/php/php7.2-fpm.sock path used repeatedly below will
-# vary from system to system and will be probably need to be changed
-
-
- ServerName localhost
- # adjust according to your installation path
- DocumentRoot /usr/share/arsse/www
-
- # adjust according to your installation path
- ProxyFCGISetEnvIf "true" SCRIPT_FILENAME "/usr/share/arsse/arsse.php"
- ProxyPreserveHost On
-
- # Nextcloud News v1.2, Tiny Tiny RSS API, TT-RSS newsfeed icons
-
- ProxyPass "unix:/var/run/php/php7.2-fpm.sock|fcgi://localhost/usr/share/arsse"
-
-
- # Nextcloud News API detection, Fever API
-
- # these locations should not be behind HTTP authentication
- ProxyPass "unix:/var/run/php/php7.2-fpm.sock|fcgi://localhost/usr/share/arsse"
-
-
-```
diff --git a/docs/en/020_Getting_Started/040_Database_Setup/000_SQLite.md b/docs/en/020_Getting_Started/040_Database_Setup/000_SQLite.md
index 91971fac..10c505fc 100644
--- a/docs/en/020_Getting_Started/040_Database_Setup/000_SQLite.md
+++ b/docs/en/020_Getting_Started/040_Database_Setup/000_SQLite.md
@@ -8,9 +8,17 @@
Minimum version
3.8.3
Configuration
- General , Specific
+ General , Specific
-SQLite requires very little set-up. By default the database will be created at the root of The Arsse's program directory (e.g. `/usr/share/arsse/arsse.db`), but this can be changed with the [`dbSQLite3File` setting](/en/Getting_Started/Configuration#page_dbSQLite3File).
+SQLite requires very little set-up. By default the database will be created at one of the following locations depending on installation method:
-Regardless of the location chosen, The Arsse **must** be able to both read from and write to the database file, as well as create files in its directory. This is because SQLite also creates a write-ahead log file and a shared-memory file during operation.
+| Installation method | Default database path |
+|---------------------|------------------------------------------------------|
+| Arch Linux package | `/var/lib/arsse/arsse.db` |
+| Debian package | `/var/lib/dbconfig-common/sqlite3/arsse/arsse` |
+| Manual installation | `arsse.db` in the The Arsse's installation directory |
+
+This path can be changed with the [`dbSQLite3File` setting](/en/Getting_Started/Configuration#page_dbSQLite3File).
+
+Regardless of the location used, The Arsse **must** be able to both read from and write to the database file, as well as create files in its directory. This is because SQLite also creates a write-ahead log file and a shared-memory file during operation.
diff --git a/docs/en/020_Getting_Started/040_Database_Setup/010_PostgreSQL.md b/docs/en/020_Getting_Started/040_Database_Setup/010_PostgreSQL.md
index 51272426..135fd243 100644
--- a/docs/en/020_Getting_Started/040_Database_Setup/010_PostgreSQL.md
+++ b/docs/en/020_Getting_Started/040_Database_Setup/010_PostgreSQL.md
@@ -8,7 +8,7 @@
Minimum version
10
Configuration
- General , Specific
+ General , Specific
If for whatever reason an SQLite database does not suit your configuration, PostgreSQL is the best alternative. It is functionally equivalent to SQLite in every way.
diff --git a/docs/en/020_Getting_Started/040_Database_Setup/020_MySQL.md b/docs/en/020_Getting_Started/040_Database_Setup/020_MySQL.md
index 4676e346..1eafe1ed 100644
--- a/docs/en/020_Getting_Started/040_Database_Setup/020_MySQL.md
+++ b/docs/en/020_Getting_Started/040_Database_Setup/020_MySQL.md
@@ -8,7 +8,7 @@
Minimum version
8.0.11
Configuration
- General , Specific
+ General , Specific
While MySQL can be used as a database for The Arsse, this is **not recommended** due to MySQL's technical limitations. It is fully functional, but may fail with some newsfeeds where other database systems do not. Additionally, it is particularly important before upgrading from one version of The Arsse to the next to back up your database: a failure in a database upgrade can corrupt your database much more easily than when using other database systems.
diff --git a/docs/en/020_Getting_Started/050_Configuration.md b/docs/en/020_Getting_Started/050_Configuration.md
index a07442c8..cb5dc26f 100644
--- a/docs/en/020_Getting_Started/050_Configuration.md
+++ b/docs/en/020_Getting_Started/050_Configuration.md
@@ -1,6 +1,14 @@
# The configuration file
-The Arsse looks for configuration in a file named `config.php` in the directory where it is installed. For example, if The Arsse is installed at `/usr/share/arsse`, it will look for configuration in the file `/usr/share/arsse/config.php`. It is not an error for this file not to exist or to be empty: The Arsse will function with no configuration whatsoever, provided other conditions allow.
+Depending on how The Arsse was installed, it will look for configuration in the following places:
+
+| Installation method | Default configuration file path |
+|---------------------|--------------------------------------------------------|
+| Arch Linux package | `/etc/webapps/arsse/config.php` |
+| Debian package | `/etc/arsse/config.php` |
+| Manual installation | `config.php` in the The Arsse's installation directory |
+
+It is not an error for this file not to exist or to be empty: The Arsse will function with no configuration whatsoever, provided other conditions allow.
The configuration file is a PHP script which returns an associative array with keys and values for one or more settings. Any settings which are not specified in the configuration file will be set to their defaults. Invalid values will cause an error on start-up, while unknown keys are ignored. A basic configuration file might look like this:
@@ -12,10 +20,10 @@ The configuration file is a PHP script which returns an associative array with k
];
```
-The `config.defaults.php` file included with copies of The Arsse contains an annotated listing of every configuration setting with its default value. The settings are also documented in more detail below.
-
# List of all settings
+The `config.defaults.php` file included with copies of The Arsse contains an annotated listing of every configuration setting with its default value. The settings are also documented in more detail below.
+
## General settings
### lang
@@ -321,7 +329,7 @@ It is also possible to specify the fully-qualified name of a class which impleme
The interval the newsfeed fetching service observes between checks for new articles. Note that requests to foreign servers are not necessarily made at this frequency: each newsfeed is assigned its own time at which to be next retrieved. This setting instead defines the length of time the fetching service will sleep between periods of activity.
-Consult "[How Often Newsfeeds Are Fetched](/en/Using_The_Arsse/Keeping_Newsfeeds_Up_to_Date#page_Appendix-how-often-newsfeeds-are-fetched)" for details on how often newsfeeds are fetched.
+Consult "[How Often Newsfeeds Are Fetched](/en/Using_The_Arsse/Other_Topics#page_How_often_newsfeeds_are_fetched)" for details on newsfeed update frequency.
### serviceQueueWidth
diff --git a/docs/en/020_Getting_Started/index.md b/docs/en/020_Getting_Started/index.md
deleted file mode 100644
index fa592565..00000000
--- a/docs/en/020_Getting_Started/index.md
+++ /dev/null
@@ -1,3 +0,0 @@
-Presently installing and setting up The Arsse is a manual process. We hope to have pre-configured installation packages available for various operating systems eventually, but for now the pages in this section should help get you up and running.
-
-Though The Arsse itself makes no assumptions about the operating system which hosts it, we use and have the most experience with Debian; the instructions contained here therefore are for Debian systems and will probably either not work with other systems or not be consistent with their conventions. Nevertheless, they should still serve as a useful guide.
diff --git a/docs/en/025_Using_The_Arsse/010_Managing_Users.md b/docs/en/025_Using_The_Arsse/010_Managing_Users.md
index 1562768c..b5d529f3 100644
--- a/docs/en/025_Using_The_Arsse/010_Managing_Users.md
+++ b/docs/en/025_Using_The_Arsse/010_Managing_Users.md
@@ -9,28 +9,28 @@ This section describes in brief some CLI commands. Please read [the general note
When first installed, The Arsse has no users configured. You may add users by executing the following command:
```sh
-sudo -u www-data php arsse.php user add "user@example.com" "example password"
+sudo arsse user add "user@example.com" "example password"
```
The password argument is optional: if no password is provided, a random one is generated and printed out:
```console
-$ sudo -u www-data php arsse.php user add "jane.doe"
+$ sudo arsse user add "jane.doe"
Ji0ivMYqi6gKxQK1MHuE
```
# Setting and changing passwords
-Setting's a user's password is practically identical to adding a password:
+Setting a user's password is nearly identical to adding a user:
```sh
-sudo -u www-data php arsse.php user set-pass "user@example.com" "new password"
+sudo arsse user set-pass "user@example.com" "new password"
```
As when adding a user, the password argument is optional: if no password is provided, a random one is generated and printed out:
```console
-$ sudo -u www-data php arsse.php user set-pass "jane.doe"
+$ sudo arsse user set-pass "jane.doe"
Ummn173XjbJT4J3Gnx0a
```
@@ -39,17 +39,50 @@ Ummn173XjbJT4J3Gnx0a
Before a user can make use of [the Fever protocol](/en/Supported_Protocols/Fever), a Fever-specific password for that user must be set. It is _highly recommended_ that this not be the samer as the user's main password. The password can be set by adding the `--fever` option to the normal password-changing command:
```sh
-sudo -u www-data php arsse.php user set-pass --fever "user@example.com" "fever password"
+sudo arsse user set-pass --fever "user@example.com" "fever password"
```
As when setting a main password, the password argument is optional: if no password is provided, a random one is generated and printed out:
```console
-$ sudo -u www-data php arsse.php user set-pass --fever "jane.doe"
+$ sudo arsse user set-pass --fever "jane.doe"
YfZJHq4fNTRUKDYhzQdR
```
+## Managing login tokens for Miniflux
+[Miniflux](/en/Supported_Protocols/Miniflux) clients may optionally log in using tokens: randomly-generated strings which act as persistent passwords. For now these must be generated using the command-line interface:
-
+```console
+$ sudo arsse token create "jane.doe"
+xRK0huUE9KHNHf_x_H8JG0oRDo4t_WV44whBtr8Ckf0=
+```
+Multiple tokens may be generated for use with different clients, and descriptive labels can be assigned for later identification:
+
+```console
+$ sudo arsse token create "jane.doe" Newsflash
+xRK0huUE9KHNHf_x_H8JG0oRDo4t_WV44whBtr8Ckf0=
+$ sudo arsse token create "jane.doe" Reminiflux
+L7asI2X_d-krinGJd1GsiRdFm2o06ZUlgD22H913hK4=
+```
+
+There are also commands for listing and revoking tokens. Please consult the integrated help for more details.
+
+# Setting and changing user metadata
+
+Users may also have various metadata properties set. These largely exist for compatibility with [the Miniflux protocol](/en/Supported_Protocols/Miniflux) and have no significant effect. One exception to this, however, is the `admin` flag, which signals whether the user may perform privileged operations where they exist in the supported protocols.
+
+The flag may be changed using the following command:
+
+```sh
+sudo arsse user set "jane.doe" admin true
+```
+
+As a shortcut it is also possible to create administrators directly:
+
+```sh
+sudo arsse user add "user@example.com" "example password" --admin
+```
+
+Please consult the integrated help for more details on metadata and their effects.
diff --git a/docs/en/025_Using_The_Arsse/020_Importing_and_Exporting.md b/docs/en/025_Using_The_Arsse/020_Importing_and_Exporting.md
index f1afb7d6..482c477f 100644
--- a/docs/en/025_Using_The_Arsse/020_Importing_and_Exporting.md
+++ b/docs/en/025_Using_The_Arsse/020_Importing_and_Exporting.md
@@ -9,7 +9,7 @@ This section describes in brief some CLI commands. Please read [the general note
It's possible to import not only newsfeeds but also folders and Fever groups using OPML files. The process is simple:
```sh
-sudo -u www-data php arsse.php import "user@example.com" "subscriptions.opml"
+sudo arsse import "user@example.com" "subscriptions.opml"
```
The importer is forgiving, but some OPML files may fail, with the reason printed out. Files are either imported in total, or not at all.
@@ -19,7 +19,7 @@ The importer is forgiving, but some OPML files may fail, with the reason printed
It's possible to export not only newsfeeds but also folders and Fever groups to OPML files. The process is simple:
```sh
-sudo -u www-data php arsse.php export "user@example.com" "subscriptions.opml"
+sudo arsse export "user@example.com" "subscriptions.opml"
```
The output might look like this:
@@ -46,9 +46,9 @@ Not all protocols supported by The Arsse allow modifying newsfeeds or folders, e
```sh
# export your newsfeeds
-sudo -u www-data php arsse.php export "user@example.com" "subscriptions.opml"
+sudo arsse export "user@example.com" "subscriptions.opml"
# make any changes you want in your editor of choice
nano "subscriptions.opml"
# re-import the modified information
-sudo -u www-data php arsse.php import "user@example.com" "subscriptions.opml" --replace
+sudo arsse import "user@example.com" "subscriptions.opml" --replace
```
diff --git a/docs/en/025_Using_The_Arsse/030_Keeping_Newsfeeds_Up_to_Date.md b/docs/en/025_Using_The_Arsse/030_Keeping_Newsfeeds_Up_to_Date.md
deleted file mode 100644
index 837de686..00000000
--- a/docs/en/025_Using_The_Arsse/030_Keeping_Newsfeeds_Up_to_Date.md
+++ /dev/null
@@ -1,49 +0,0 @@
-[TOC]
-
-# Preface
-
-In normal operation The Arsse is expected to regularly check whether newsfeeds might have new articles, then fetch them and process them to present new or updated articles to clients. This can be achieved either by having The Arsse operate a persistent background process (termed a [daemon](https://en.wikipedia.org/wiki/Daemon_(computing)) or service), or by using an external scheduler to periodically perform single checks. Normally a daemon is preferred.
-
-There are many ways to administer daemons, and many schedulers can be used. This section outlines a few, but many other arrangements are possible.
-
-# As a daemon via systemd
-
-The Arsse includes a sample systemd service unit file which can be used to quickly get a daemon running with the following procedure:
-
-```sh
-# Copy the service unit
-sudo cp "/usr/share/arsse/dist/arsse.service" "/etc/systemd/system"
-# Modify the unit file if needed
-sudoedit "/etc/systemd/system/arsse.service"
-# Enable and start the service
-sudo systemctl enable --now arsse
-```
-
-The Arsse's feed updater can then be manipulated as with any other service. Consult [the `systemctl` manual](https://www.freedesktop.org/software/systemd/man/systemctl.html) for details.
-
-# As a cron job
-
-Keeping newsfeeds updated with [cron](https://en.wikipedia.org/wiki/Cron) is not difficult. Simply run the following command:
-
-
-```sh
-sudo crontab -u www-data -e
-```
-
-And add a line such as this one:
-
-```
-*/2 * * * * /usr/bin/env php /usr/share/arsse/arsse.php refresh-all
-```
-
-Thereafter The Arsse's will be scheduled to check newsfeeds every two minutes. Consult the manual pages for the `crontab` [format](http://man7.org/linux/man-pages/man5/crontab.5.html) and [command](http://man7.org/linux/man-pages/man1/crontab.1.html) for details.
-
-# Appendix: how often newsfeeds are fetched
-
-Though by default The Arsse will wake up every two minutes, newsfeeds are not actually downloaded so frequently. Instead, each newsfeed is assigned a time at which it should next be fetched, and once that time is reached a [conditional request](https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests) is made. The interval between requests for a particular newsfeed can vary from 15 minutes to 24 hours based on multiple factors such as:
-
-- The length of time since the newsfeed last changed
-- The interval between publishing of articles in the newsfeed
-- Whether the last fetch or last several fetches resulted in error
-
-As a general rule, newsfeeds which change frequently are checked frequently, and those which change seldom are fetched at most daily.
diff --git a/docs/en/025_Using_The_Arsse/030_Other_Topics.md b/docs/en/025_Using_The_Arsse/030_Other_Topics.md
new file mode 100644
index 00000000..fcebbee9
--- /dev/null
+++ b/docs/en/025_Using_The_Arsse/030_Other_Topics.md
@@ -0,0 +1,34 @@
+[TOC]
+
+# Preface
+
+This section describes in brief some CLI commands. Please read [the general notes on the command line interface](index) before continuing.
+
+# Refreshing newsfeeds with a cron job
+
+Normally The Arsse has a systemd service which checks newsfeeds for updates and processes them into its database for the user. If for whatever reason this is not practical a [cron](https://en.wikipedia.org/wiki/Cron) job may be used instead.
+
+Keeping newsfeeds updated with cron is not difficult. Simply run the following command:
+
+
+```sh
+sudo crontab -u arsse -e
+```
+
+And add a line such as this one:
+
+```
+*/2 * * * * /usr/bin/arsse refresh-all
+```
+
+Thereafter The Arsse's will be scheduled to check newsfeeds every two minutes. Consult the manual pages for the `crontab` [format](http://man7.org/linux/man-pages/man5/crontab.5.html) and [command](http://man7.org/linux/man-pages/man1/crontab.1.html) for details.
+
+# How often newsfeeds are fetched
+
+Though by default The Arsse will wake up every two minutes, newsfeeds are not actually downloaded so frequently. Instead, each newsfeed is assigned a time at which it should next be fetched, and once that time is reached a [conditional request](https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests) is made. The interval between requests for a particular newsfeed can vary from 15 minutes to 24 hours based on multiple factors such as:
+
+- The length of time since the newsfeed last changed
+- The interval between publishing of articles in the newsfeed
+- Whether the last fetch or last several fetches resulted in error
+
+As a general rule, newsfeeds which change frequently are checked frequently, and those which change seldom are fetched at most daily.
diff --git a/docs/en/025_Using_The_Arsse/040_Upgrading_to_a_New_Version.md b/docs/en/025_Using_The_Arsse/040_Upgrading_to_a_New_Version.md
deleted file mode 100644
index 2b1afa14..00000000
--- a/docs/en/025_Using_The_Arsse/040_Upgrading_to_a_New_Version.md
+++ /dev/null
@@ -1,12 +0,0 @@
-Upgrading The Arsse is usually simple:
-
-1. Download the latest release
-2. Check the `UPGRADING` file for any special notes
-3. Stop the newsfeed refreshing service if it is running
-4. Extract the new version on top of the old one
-5. Ensure permissions are still correct
-6. Restart the newsfeed refreshing service
-
-By default The Arsse will perform any required database schema upgrades when the new version is executed, and release packages contain all newly required library dependencies.
-
-Occasionally changes to Web server configuration have been required, when new protocols become supported; such changes are always explicit in the `UPGRADING` file
diff --git a/docs/en/025_Using_The_Arsse/index.md b/docs/en/025_Using_The_Arsse/index.md
index 923d6666..475fdce8 100644
--- a/docs/en/025_Using_The_Arsse/index.md
+++ b/docs/en/025_Using_The_Arsse/index.md
@@ -2,19 +2,8 @@
This section details a few administrative tasks which may need to be performed after installing The Arsse. As no Web-based administrative interface is included, these tasks are generally performed via command line interface.
-Though this section describes some commands briefly, complete documentation of The Arsse's command line interface is not included in this manual. Documentation for CLI commands can instead be viewed with the CLI itself by executing `php arsse.php --help`.
+Though this section describes some commands briefly, complete documentation of The Arsse's command line interface is not included in this manual. Documentation for CLI commands can instead be viewed using the system manual service by executing `man arsse`.
# A Note on Command Invocation
-Particularly if using an SQLite database, it's important that administrative commands be executed as the same user who owns The Arsse's files. To that end the examples in this section all use the verbose formulation `sudo -u www-data php arsse.php` (with `www-data` being the user under which Web servers run in Debian), but it is possible to simplify invocation to `sudo arsse` if an executable file named `arsse` is created somewhere in the sudo path with the following content:
-
-```php
-#! /usr/bin/env php
-
+ Supported since
+ 0.9.0
+ Base URL
+ /
+ API endpoint
+ /v1/
+ Specifications
+ API Reference , Filtering Rules
+
+
+The Miniflux protocol is a fairly well-designed protocol supporting a wide variety of operations on newsfeeds, folders (termed "categories"), and articles; it also allows for user administration, and native OPML importing and exporting. Architecturally it is similar to the Nextcloud News protocol, but has more capabilities.
+
+Miniflux version 2.0.28 is emulated, though not all features are implemented
+
+# Missing features
+
+- JSON Feed format is not suported
+- Various feed-related features are not supported; attempting to use them has no effect
+ - Rewrite rules and scraper rules
+ - Custom User-Agent strings
+ - The `disabled`, `ignore_http_cache`, and `fetch_via_proxy` flags
+ - Changing the URL, username, or password of a feed
+ - Manually refreshing feeds
+- Titles and types are not available during feed discovery and are filled with generic data
+- Reading time is not calculated and will always be zero
+- Only the first enclosure of an article is retained
+- Comment URLs of articles are not exposed
+
+# Differences
+
+- Various error codes and messages differ due to significant implementation differences
+- The "All" category is treated specially (see below for details)
+- Feed and category titles consisting only of whitespace are rejected along with the empty string
+- Filtering rules may not function identically (see below for details)
+- The `checked_at` field of feeds indicates when the feed was last updated rather than when it was last checked
+- Creating a feed with the `scrape` property set to `true` might not return scraped content for the initial synchronization
+- Querying articles for both read/unread and removed statuses will not return all removed articles
+- Search strings will match partial words
+- OPML import either succeeds or fails atomically: if one feed fails, no feeds are imported
+
+# Behaviour of filtering (block and keep) rules
+
+The Miniflux documentation gives only a brief example of a pattern for its filtering rules; the allowed syntax is described in full [in Google's documentation for RE2](https://github.com/google/re2/wiki/Syntax). Being a PHP application, The Arsse instead accepts [PCRE syntax](http://www.pcre.org/original/doc/html/pcresyntax.html) (or since PHP 7.3 [PCRE2 syntax](https://www.pcre.org/current/doc/html/pcre2syntax.html)), specifically in UTF-8 mode. Delimiters should not be included, and slashes should not be escaped; anchors may be used if desired. For example `^(?i)RE/MAX$` is a valid pattern.
+
+For convenience the patterns are tested after collapsing whitespace. Unlike Miniflux, The Arsse tests the patterns against an article's author-supplied categories if they do not match its title. Also unlike Miniflux, when filter rules are modified they are re-evaluated against all applicable articles immediately.
+
+# Special handling of the "All" category
+
+Nextcloud News' root folder and Tiny Tiny RSS' "Uncategorized" catgory are mapped to Miniflux's initial "All" category. This Miniflux category can be renamed, but it cannot be deleted. Attempting to do so will delete the child feeds it contains, but not the category itself.
+
+Because the root folder does not existing in the database as a separate entity, it will always sort first when ordering by `category_id` or `category_title`.
+
+# Interaction with nested categories
+
+Tiny Tiny RSS is unique in allowing newsfeeds to be grouped into categories nested to arbitrary depth. When newsfeeds are placed into nested categories, they simply appear in the top-level category when accessed via the Miniflux protocol. This does not affect OPML exports, where full nesting is preserved.
diff --git a/docs/en/030_Supported_Protocols/010_Nextcloud_News.md b/docs/en/030_Supported_Protocols/010_Nextcloud_News.md
index a2c34d04..17f5d2df 100644
--- a/docs/en/030_Supported_Protocols/010_Nextcloud_News.md
+++ b/docs/en/030_Supported_Protocols/010_Nextcloud_News.md
@@ -24,7 +24,6 @@ It allows organizing newsfeeds into single-level folders, and supports a wide ra
- When marking articles as starred the feed ID is ignored, as they are not needed to establish uniqueness
- The feed updater ignores the `userId` parameter: feeds in The Arsse are deduplicated, and have no owner
- The `/feeds/all` route lists only feeds which should be checked for updates, and it also returns all `userId` attributes as empty strings: feeds in The Arsse are deduplicated, and have no owner
-- The API's "updater" routes do not require administrator priviledges as The Arsse has no concept of user classes
- The "updater" console commands mentioned in the protocol specification are not implemented, as The Arsse does not implement the required Nextcloud subsystems
- The `lastLoginTimestamp` attribute of the user metadata is always the current time: The Arsse's implementation of the protocol is fully stateless
- Syntactically invalid JSON input will yield a `400 Bad Request` response instead of falling back to GET parameters
diff --git a/docs/en/030_Supported_Protocols/030_Fever.md b/docs/en/030_Supported_Protocols/030_Fever.md
index 094a909f..438fe399 100644
--- a/docs/en/030_Supported_Protocols/030_Fever.md
+++ b/docs/en/030_Supported_Protocols/030_Fever.md
@@ -23,7 +23,6 @@ The Fever protocol is incomplete, unusual, _and_ a product of proprietary softwa
- All feeds are considered "Kindling"
- The "Hot Links" feature is not implemented; when requested, an empty array will be returned. As there is no way to classify a feed as a "Spark" in the protocol itself and no documentation exists on how link temperature was calculated, an implementation is unlikely to appear in the future
-- Favicons are not currently supported; all feeds have a simple blank image as their favicon unless the client finds the icons itself
# Special considerations
@@ -38,7 +37,7 @@ The Fever protocol is incomplete, unusual, _and_ a product of proprietary softwa
# Interaction with HTTP Authentication
-We are not aware of any Fever clients which respond to HTTP authentication challenges. If the Web server or The Arsse is configured to require successful HTTP authentication, Fever clients are not likely to be able to connect properly.
+Fever was not designed with HTTP authentication in mind, and few clients respond to challenges. If the Web server or The Arsse is configured to require successful HTTP authentication, most Fever clients are not likely to be able to connect properly.
# Interaction with Folders
diff --git a/docs/en/030_Supported_Protocols/index.md b/docs/en/030_Supported_Protocols/index.md
index 7e58df6e..b9a9e47a 100644
--- a/docs/en/030_Supported_Protocols/index.md
+++ b/docs/en/030_Supported_Protocols/index.md
@@ -1,5 +1,6 @@
The Arsse was designed from the start as a server for multiple synchronization protocols which clients can make use of. Currently the following protocols are supported:
+- [Miniflux](Miniflux)
- [Nextcloud News](Nextcloud_News)
- [Tiny Tiny RSS](Tiny_Tiny_RSS)
- [Fever](Fever)
diff --git a/docs/en/040_Compatible_Clients.md b/docs/en/040_Compatible_Clients.md
index 9122387f..dcdc33c7 100644
--- a/docs/en/040_Compatible_Clients.md
+++ b/docs/en/040_Compatible_Clients.md
@@ -1,31 +1,89 @@
-The Arsse does not at this time have any first party clients. However, because The Arsse [supports existing protocols](/en/Supported_Protocols), most clients built for these protocols are compatible with The Arsse. Below are those that we personally know of and have tested with The Arsse.
+The Arsse does not at this time have any first party clients. However, because The Arsse [supports existing protocols](/en/Supported_Protocols), most clients built for these protocols are compatible with The Arsse. Below are those that we personally know of and have tested with The Arsse, presented in alphabetical order.
Name
OS
- Protocol
+ Protocol
Notes
+ Miniflux
Nextcloud News
Tiny Tiny RSS
Fever
+ Web
- Desktop
+
+
+ Miniflux Reader
+
+ ✔
+ ✘
+ ✘
+ ✘
+ Functional, but has some display glitches.
+
+
+ NX News
+
+ ✘
+ ✔
+ ✘
+ ✘
+ Extremely basic client.
+
+
+ reminiflux
+
+ ✔
+ ✘
+ ✘
+ ✘
+
+ Three-pane alternative front-end for Minflux. Does not include functionality for managing feeds. Requires token authentication.
+
+
+
+ Tiny Tiny RSS Reader
+
+ ✘
+ ✘
+ ✔
+ ✘
+
+
+
+
+
+ Tiny Tiny RSS Progressive Web App
+
+ ✘
+ ✘
+ ✔
+ ✘
+
+ Does not (yet ) support HTTP authentication. Does not include functionality for managing feeds.
+
+
+
+
+
+ Desktop
FeedReader
Linux
+ ✘
✔
✔
✘
- Excellent reader; one of the best on any platform.
+ Excellent reader; discontinued in favour of NewsFlash.
Not compatible with HTTP authentication when using TT-RSS.
@@ -33,6 +91,7 @@ The Arsse does not at this time have any first party clients. However, because T
Liferea
Linux
✘
+ ✘
✔
✘
@@ -44,6 +103,7 @@ The Arsse does not at this time have any first party clients. However, because T
Linux, macOS
✔
✔
+ ✔
✘
Terminal-based client.
@@ -52,11 +112,12 @@ The Arsse does not at this time have any first party clients. However, because T
NewsFlash
Linux
+ ✔
✘
✘
✔
- Successor to FeedReader.
+ Successor to FeedReader. One of the best on any platform
@@ -64,6 +125,7 @@ The Arsse does not at this time have any first party clients. However, because T
macOS
✘
✘
+ ✘
✔
Also available for iOS.
@@ -72,18 +134,20 @@ The Arsse does not at this time have any first party clients. However, because T
RSS Guard
Windows, macOS, Linux
+ ✘
✔
✔
✘
- Very basic client; now discontinued.
+ Very basic client.
- Tiny Tiny RSS Reader
+ Tiny Tiny RSS Reader
Windows
✘
+ ✘
✔
✘
@@ -93,11 +157,12 @@ The Arsse does not at this time have any first party clients. However, because T
- Mobile
+ Mobile
CloudNews
iOS
+ ✘
✔
✘
✘
@@ -109,6 +174,7 @@ The Arsse does not at this time have any first party clients. However, because T
FeedMe
Android
✘
+ ✘
✔
✘
@@ -119,16 +185,48 @@ The Arsse does not at this time have any first party clients. However, because T
Fiery Feeds
iOS
✘
+ ✘
✔
✔
Rentalware - For the software to be usable (you can't even add feeds otherwise) a subscription fee must be paid.
+ Support HTTP authentication with Fever.
Currently keeps showing items in the unread badge which have already been read.
+
+ Geekttrss
+ Android
+ ✘
+ ✘
+ ✔
+ ✘
+
+
+
+
+
+ Microflux for Miniflux
+ Android
+ ✔
+ ✘
+ ✘
+ ✘
+
+
+
+ NewsJet RSS
+ Android
+ ✘
+ ✘
+ ✔
+ ✘
+
+
Newsout
Android, iOS
+ ✘
✔
✘
✘
@@ -139,6 +237,7 @@ The Arsse does not at this time have any first party clients. However, because T
Nextcloud News
Android
+ ✘
✔
✘
✘
@@ -149,6 +248,7 @@ The Arsse does not at this time have any first party clients. However, because T
OCReader
Android
+ ✘
✔
✘
✘
@@ -159,16 +259,38 @@ The Arsse does not at this time have any first party clients. However, because T
Android
✘
✘
+ ✘
✔
Fetches favicons independently.
+
+ Readrops
+ Android
+ ✘
+ ✔
+ ✘
+ ✘
+
+
+
+ Reed
+ Android
+ ✔
+ ✘
+ ✘
+ ✘
+
+ Binaries only available from GitHub.
+
+
Reeder
iOS
✘
✘
+ ✘
✔
Also available for macOS.
@@ -178,6 +300,7 @@ The Arsse does not at this time have any first party clients. However, because T
Tiny Tiny RSS
Android
✘
+ ✘
✔
✘
@@ -188,6 +311,7 @@ The Arsse does not at this time have any first party clients. However, because T
TTRSS-Reader
Android
✘
+ ✘
✔
✘
@@ -199,6 +323,7 @@ The Arsse does not at this time have any first party clients. However, because T
iOS
✘
✘
+ ✘
✔
Trialware with one-time purchase.
@@ -214,10 +339,11 @@ The Arsse does not at this time have any first party clients. However, because T
Name
OS
- Protocol
+ Protocol
Notes
+ Miniflux
Nextcloud News
Tiny Tiny RSS
Fever
@@ -228,6 +354,7 @@ The Arsse does not at this time have any first party clients. However, because T
FeedTheMonkey
Linux
✘
+ ✘
✔
✘
@@ -235,13 +362,66 @@ The Arsse does not at this time have any first party clients. However, because T
- Newsie
- Ubuntu Touch
+ Fuoten
+ Sailfish
+ ✘
+ ✔
+ ✘
+ ✘
+
+
+
+
+
+ Kaktus
+ Sailfish, BlackBerry
+ ✘
+ ✘
+ ✔
+ ✘
+
+
+
+
+
+
+ Miniflutt
+ Android
+ ✔
+ ✘
+ ✘
+ ✘
+ Does not display articles (see bug )
+
+
+ Newsie
+ Ubuntu Touch
+ ✘
✔
✘
✘
- Does not support HTTP authentication.
@@ -249,6 +429,7 @@ The Arsse does not at this time have any first party clients. However, because T
macOS
✘
✘
+ ✘
✔
Requires purchase. Presumed to work.
@@ -259,6 +440,7 @@ The Arsse does not at this time have any first party clients. However, because T
Windows
✘
✘
+ ✘
✔
Requires manual configuration.
@@ -268,11 +450,23 @@ The Arsse does not at this time have any first party clients. However, because T
tiny Reader RSS
iOS
✘
+ ✘
✔
✘
Does not support HTTP authentication.
+
+ ttrss
+ Sailfish
+ ✘
+ ✘
+ ✔
+ ✘
+
+
+
+
diff --git a/docs/theme/arsse/arsse.css b/docs/theme/arsse/arsse.css
index 94b17ab9..9971fbaa 100644
--- a/docs/theme/arsse/arsse.css
+++ b/docs/theme/arsse/arsse.css
@@ -1,2 +1,2 @@
/*! normalize.css v4.1.1 | MIT License | github.com/necolas/normalize.css */
-html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;font-size:14px}body{margin:0;padding:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block}audio:not([controls]){display:none;height:0}progress,sub,sup{vertical-align:baseline}.s-content pre code:after,.s-content pre code:before,[hidden],template{display:none}a{background-color:transparent;-webkit-text-decoration-skip:objects;text-decoration:none;color:#e63c2f}a:active,a:hover{outline-width:0}abbr[title]{border-bottom:none;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}.s-content blockquote cite,dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;clear:both;margin:1em 0;border:0;border-top:1px solid #ddd}button,input,select,textarea{font:inherit;margin:0}optgroup{font-weight:700}button,hr,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-input-placeholder{color:inherit;opacity:.54}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}*,:after,:before{box-sizing:border-box}@media (min-width:850px){html{font-size:16px}}body,html{height:100%;background-color:#fff;color:#15284b}.Columns__left{background-color:#e8d5d3}.Columns__right__content{padding:10px;background-color:#fff}@media (max-width:768px){html:not(.no-js) .Collapsible__content{height:0;overflow:hidden;transition:height 400ms ease-in-out}}.Collapsible__trigger{margin:12px;padding:7px 10px;background-color:transparent;border:0;float:right;background-image:none;-webkit-filter:none;filter:none;box-shadow:none}.Collapsible__trigger__bar{display:block;width:18px;height:2px;margin-top:2px;margin-bottom:3px;background-color:#e8d5d3}.Collapsible__trigger:hover{background-color:#93b7bb;box-shadow:none}.Collapsible__trigger:hover .Collapsible__trigger__bar{background-color:#15284b}@media screen and (min-width:769px){body{background-color:#15284b}.Navbar{position:fixed;z-index:1030;width:100%}.Collapsible__trigger{display:none!important}.Collapsible__content{display:block!important}.Columns{height:100%}.Columns:after,.Columns:before{content:" ";display:table}.Columns:after{clear:both}.Columns__left,.Columns__right{position:relative;min-height:1px;float:left;overflow:auto;height:100%}.Columns__left{width:25%;border-right:1px solid #e7e7e9;overflow-x:hidden}.Columns__right{width:75%}.Columns__right__content{padding:0 20px 20px;min-height:100%}}.Page{max-width:860px}body{font-family:"Cabin","Trebuchet MS",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;font-feature-settings:"kern" 1;-webkit-font-kerning:normal;font-kerning:normal;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;line-height:1.618}h1,h2,h3,h4,h5,h6{font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif}.s-content h1,.s-content h2,.s-content h3,.s-content h4,.s-content h5,.s-content h6{cursor:text;line-height:1.4em;margin:2em 0 .5em}.s-content h1 code,.s-content h1 tt,.s-content h2 code,.s-content h2 tt,.s-content h3 code,.s-content h3 tt,.s-content h4 code,.s-content h4 tt,.s-content h5 code,.s-content h5 tt,.s-content h6 code,.s-content h6 tt{font-size:inherit}.s-content h1 i,.s-content h2 i,.s-content h3 i,.s-content h4 i,.s-content h5 i,.s-content h6 i{font-size:.7em}.s-content h1 p,.s-content h2 p,.s-content h3 p,.s-content h4 p,.s-content h5 p,.s-content h6 p{margin-top:0}.s-content h1{margin-top:0;font-size:2.618rem}.s-content h2{font-size:2rem}.s-content h3{font-size:1.618rem}.s-content h4,.s-content h5,.s-content h6,.s-content small{font-size:1.309rem}.s-content a{text-decoration:underline}.s-content p{margin-bottom:1.3em}.s-content ol,.s-content ul{padding-left:2em}.s-content ul p,.s-content ul ul{margin:0}.s-content dl{padding:0}.s-content dl dt{font-weight:700;font-style:italic;padding:0;margin:15px 0 5px}.s-content dl dt:first-child{padding:0}.s-content dl dd{margin:0 0 15px;padding:0 15px}.s-content blockquote{margin:.75em 2em;padding:.5em 1em;font-style:italic;border-left:.25em solid #15284b}.s-content blockquote cite:before{content:"\2014";padding-right:.5em}.s-content table{width:100%;padding:0;margin-bottom:1em;border-collapse:separate;border-spacing:2px;border:2px solid #b3aab1}.s-content table+table{margin-top:1em}.s-content table tr{background-color:#fff;margin:0;padding:0;border-top:0}.s-content table tr:nth-child(2n){background-color:transparent}.s-content table th{font-weight:700;background:#e8d5d3}.s-content table td,.s-content table th{margin:0;padding:.5em}.s-content blockquote>:first-child,.s-content dl dd>:first-child,.s-content dl dt>:first-child,.s-content ol>:first-child,.s-content table td>:first-child,.s-content table th>:first-child,.s-content ul>:first-child{margin-top:0}.s-content blockquote>:last-child,.s-content dl dd>:last-child,.s-content dl dt>:last-child,.s-content ol>:last-child,.s-content table td>:last-child,.s-content table th>:last-child,.s-content ul>:last-child{margin-bottom:0}.s-content img{max-width:100%;display:block;margin:0 auto}.s-content code{font-family:Monaco,Menlo,Consolas,"Lucida Console","Courier New",monospace;padding-top:.1rem;padding-bottom:.1rem;background:#f9f5f4;border-radius:0;box-shadow:none;display:inline-block;padding:.5ch;border:0}.s-content code:after,.s-content code:before{letter-spacing:-.2em;content:"\00a0"}.s-content pre{background:#f5f2f0;line-height:1.5em;overflow:auto;border:0;border-radius:0;padding:.75em 20px;margin:0 -20px 20px}.s-content pre code{margin:0;padding:0;white-space:pre;box-shadow:none}.s-content pre code,.s-content pre tt{background-color:transparent;border:0}.s-content ins,.s-content u{text-decoration:none;border-bottom:1px solid #15284b}.s-content del a,.s-content ins a,.s-content u a{color:inherit}a.Link--external:after{content:" " url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAVklEQVR4Xn3PgQkAMQhDUXfqTu7kTtkpd5RA8AInfArtQ2iRXFWT2QedAfttj2FsPIOE1eCOlEuoWWjgzYaB/IkeGOrxXhqB+uA9Bfcm0lAZuh+YIeAD+cAqSz4kCMUAAAAASUVORK5CYII=)}a.Link--broken{color:red}p{margin:0 0 1em}.Button{display:inline-block;text-align:center;vertical-align:middle;touch-action:manipulation;cursor:pointer;background-image:none;border:1px solid transparent;white-space:nowrap;margin-bottom:0}.Button--small{font-size:12px;line-height:1.5;border-radius:3px}.Button--default{color:#333;background-color:#fff;border-color:#ccc}.Button--default.Button--active{color:#333;background-color:#e6e6e6;border-color:#adadad}.Brand,.Nav__item a:hover{color:#15284b;text-shadow:none}.Brand,.Navbar{background-color:#e63c2f}.Brand{display:block;padding:.75em .6em;font-size:2rem;font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif}.Navbar{box-shadow:0 1px 5px rgba(0,0,0,.25);margin-bottom:0}.CodeToggler{padding:0 20px}.CodeToggler__text{font-size:12px;line-height:1.5;padding:6px 10px 6px 0;display:inline-block;vertical-align:middle}.no-js .CodeToggler{display:none}.Nav{margin:0;padding:0}.Nav__arrow{display:inline-block;position:relative;width:16px;margin-left:-16px}.Nav__arrow:before{position:absolute;display:block;content:"";margin:-.25em 0 0 -.4em;left:50%;top:50%;border-right:.15em solid #15284b;border-top:.15em solid #15284b;transform:rotate(45deg);transition-duration:.3s}.Nav__item,.Nav__item a{display:block}.Nav__item a{margin:0;padding:6px 15px 6px 20px;font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;font-weight:400;color:#15284b;text-shadow:none}.Nav__item a:hover{background-color:#93b7bb}.Nav .Nav{margin-left:15px}html:not(.no-js) .Nav .Nav{height:0;transition:height 400ms ease-in-out;overflow:hidden}.Nav .Nav .Nav__item a{margin:0 0 0 -15px;padding:3px 30px;font-family:"Cabin","Trebuchet MS",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;color:#15284b;opacity:.7}.HomepageButtons .Button--hero:hover,.Nav .Nav .Nav__item a:hover{opacity:1}.Nav .Nav .Nav__item--active a{color:#15284b}.Nav__item--active>a,.Nav__item--open>a{background-color:#93b7bb}.Nav__item--open>a>.Nav__arrow:before{margin-left:-.25em;transform:rotate(135deg)}.Page__header{margin:0 0 10px;padding:0}.Page__header:after,.Page__header:before{content:" ";display:table}.Page__header:after{clear:both}.Page__header h1{margin:0;padding:0;line-height:57px}.Page__header--separator{height:.6em}.Page__header a{text-decoration:none}.Page__header .EditOn,.Page__header .ModifiedDate{float:left;font-size:10px;color:gray}.Page__header .EditOn{float:right}.Links,.Twitter{padding:0 20px}.Links a{font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;font-weight:400;color:#15284b;line-height:2em}.Twitter{font:11px/18px "Helvetica Neue",Arial,sans-serif}.Twitter__button{text-decoration:none;display:inline-block;vertical-align:top;zoom:1;position:relative;height:20px;box-sizing:border-box;padding:1px 8px 1px 6px;background-color:#1b95e0;color:#fff;border-radius:3px;font-weight:500;cursor:pointer}.Twitter__button .Twitter__button__label{display:inline-block;vertical-align:top;zoom:1;margin-left:3px;white-space:nowrap}.Twitter__button svg{position:relative;top:2px;display:inline-block;width:14px;height:14px}.PoweredBy{padding:0 20px 1rem;font-size:1.309rem}.Search{position:relative}.Search__field{display:block;width:100%;height:34px;padding:6px 30px 6px 20px;color:#555;border-width:0 0 1px;border-bottom:1px solid #ccc;background:#fff;transition:border-color ease-in-out .15s}.Search__field:focus{border-color:#93b7bb;outline:0}.Search__icon{position:absolute;right:9px;top:9px;width:16px;height:16px}.Navbar .Search{float:right;margin:8px 20px}.Navbar .Search__field{box-shadow:inset 0 1px 1px rgba(0,0,0,.075);border-width:0;border-radius:4px;padding-left:10px}.TableOfContentsContainer{float:right;min-width:300px;max-width:25%;padding-left:1em}.TableOfContentsContainer__title{margin-bottom:0!important}.TableOfContentsContainer__content{border:1px solid #efefef;border-width:4px 2px 2px 6px}.TableOfContentsContainer__content>.TableOfContents>li+li{border-top:1px solid #ddd}ul.TableOfContents{font-size:1rem;padding-left:0;margin:0;list-style-type:none;border-left:6px solid #e8d5d3}ul.TableOfContents p{margin-bottom:0}ul.TableOfContents a{text-decoration:none;display:block;padding:.2em 0 .2em .75em}ul.TableOfContents .TableOfContents{padding-left:.75em}.Pager{padding-left:0;margin:1em 0;list-style:none;text-align:center}.Pager:after,.Pager:before{content:" ";display:table}.Pager,.Pager:after{clear:both}.Pager li{display:inline}.Pager li>a{display:inline-block;padding:5px 14px;background-color:#fff}.Pager li>a:focus,.Pager li>a:hover{text-decoration:none}.Pager--next>a{float:right}.Pager--prev>a{float:left}.Checkbox{position:relative;display:block;padding-left:30px;cursor:pointer}.Checkbox input{position:absolute;z-index:-1;opacity:0}.Checkbox__indicator{position:absolute;top:50%;left:0;width:20px;height:20px;margin-top:-10px;background:#e6e6e6}.Checkbox__indicator:after{position:absolute;display:none;content:""}.Checkbox input:focus~.Checkbox__indicator,.Checkbox:hover input~.Checkbox__indicator{background:#ccc}.Checkbox input:checked~.Checkbox__indicator{background:#15284b}.Checkbox input:checked~.Checkbox__indicator:after{display:block}.Checkbox input:checked:focus~.Checkbox__indicator,.Checkbox:hover input:not([disabled]):checked~.Checkbox__indicator{background:#93b7bb}.Checkbox input:disabled~.Checkbox__indicator{pointer-events:none;opacity:.6;background:#e6e6e6}.Checkbox .Checkbox__indicator:after{top:4px;left:8px;width:5px;height:10px;transform:rotate(45deg);border:solid #fff;border-width:0 2px 2px 0}.Checkbox input:disabled~.Checkbox__indicator:after{border-color:#7b7b7b}.Hidden{display:none}.Container{margin-right:auto;margin-left:auto}.Container--inner{width:80%;margin:0 auto}@media (min-width:1200px){.Container{width:1170px}}@media (min-width:992px){.Container{width:970px}}@media (min-width:769px){.Container{width:750px}}.Homepage{background-color:#fff;border-radius:0;border:0;color:#15284b;overflow:hidden;padding-bottom:0;margin-bottom:0;box-shadow:none}.HomepageTitle h2{width:80%;font-size:30px;margin:20px auto;text-align:center}.HomepageImage img{display:block;max-width:80%;margin:0 auto;height:auto}.HomepageButtons{padding:20px 0;background-color:#e8d5d3;text-align:center}.HomepageButtons:after,.HomepageButtons:before{content:" ";display:table}.HomepageButtons:after{clear:both}.HomepageButtons .Button--hero{padding:20px 30px;border-radius:0;text-shadow:none;opacity:.8;margin:0 10px;text-transform:uppercase;border:5px solid #15284b;font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;background-image:none;-webkit-filter:none;filter:none;box-shadow:none}@media (max-width:768px){.HomepageButtons .Button--hero{display:block;margin-bottom:10px}}.HomepageButtons .Button--hero.Button--secondary{background-color:#93b7bb;color:#15284b}.HomepageButtons .Button--hero.Button--primary{background-color:#15284b;color:#e8d5d3}.HomepageContent{background-color:#fff;padding:40px 0}.HomepageContent ol li,.HomepageContent ul li{list-style:none;margin-bottom:.5em;position:relative}.HomepageContent ol li:before,.HomepageContent ul li:before{position:absolute;top:50%;left:-1.5em;content:"";width:0;height:0;border:.5em solid transparent;border-left:.5em solid #93b7bb;float:left;display:block;margin-top:-.5em}.HomepageContent .HeroText,.HomepageFooter__links li a{font-size:16px;font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif}.HomepageContent .HeroText{font-weight:300;margin-bottom:20px;line-height:1.4}@media (min-width:769px){.HomepageContent{padding:40px 20px}.HomepageContent .HeroText{font-size:21px}.HomepageContent .Row{margin:0 -15px}.HomepageContent .Row__half,.HomepageContent .Row__quarter,.HomepageContent .Row__third{float:left;position:relative;min-height:1px;padding-left:15px;padding-right:15px}.HomepageContent .Row__third{width:33.333333%}.HomepageContent .Row__half{width:50%}.HomepageContent .Row__quarter{width:25%}}.HomepageFooter{background-color:#15284b;color:#93b7bb;border:0;box-shadow:none}.HomepageFooter:after,.HomepageFooter:before{content:" ";display:table}.HomepageFooter:after{clear:both}@media (max-width:768px){.HomepageFooter{padding:0 20px;text-align:center}.HomepageFooter .HomepageFooter__links{padding-left:0;list-style-type:none}}@media (min-width:769px){.HomepageFooter .HomepageFooter__links{float:left}.HomepageFooter .HomepageFooter__twitter{float:right}}.HomepageFooter__links,.HomepageFooter__twitter{margin:40px 0}.HomepageFooter__links li a{line-height:32px;font-weight:700}.HomepageFooter__links li a:hover{text-decoration:underline}.HomepageFooter .Twitter__button{margin-bottom:20px}@media print{*{text-shadow:none!important;color:#000!important;background:0 0!important;box-shadow:none!important}h1,h2,h3,h4,h5,h6{page-break-after:avoid;page-break-before:auto}blockquote,img,pre{page-break-inside:avoid}blockquote,pre{border:1px solid #999;font-style:italic}img{border:0}a,a:visited{text-decoration:underline}abbr[title]:after{content:" (" attr(title) ")"}q{quotes:none}.s-content a[href^="#"]:after,q:before{content:""}q:after{content:" (" attr(cite) ")"}.PageBreak{display:block;page-break-before:always}.NoPrint,.Pager,aside{display:none}.Columns__right{width:100%!important}.s-content a:after{content:" (" attr(href) ")";font-size:80%;word-wrap:break-word}h1 a[href]:after{font-size:50%}}@font-face{font-family:'League Gothic';src:url(fonts/leaguegothic.woff2) format('woff2'),url(fonts/leaguegothic.woff) format('woff');font-style:normal;font-display:swap}@font-face{font-family:'Cabin';src:url(fonts/cabin-regular.woff2) format('woff2'),url(fonts/cabin-regular.woff) format('woff');font-weight:400;font-style:normal;font-display:swap}@font-face{font-family:'Cabin';src:url(fonts/cabin-italic.woff2) format('woff2'),url(fonts/cabin-italic.woff) format('woff');font-style:italic;font-display:swap}@font-face{font-family:'Cabin';src:url(fonts/cabin-bold.woff2) format('woff2'),url(fonts/cabin-bold.woff) format('woff');font-weight:700;font-style:normal;font-display:swap}.s-content code::after,.s-content code::before,a.Link--external::after{content:''}pre .s-content code{display:inline}.s-content table tbody,.s-content table thead{background-color:#fff}.s-content table tr:nth-child(2n) td{background-color:#f9f5f4}.s-content table td,.s-content table th{border:0}.Nav__item .Nav__item,.s-content table{font-size:1rem}.Brand,h1,h2,h3,h4,h5,h6{font-weight:400}.Button,.Pager li>a{border-radius:0}.HomepageButtons .Button--hero{font-weight:400;font-size:1.309rem}.Page__header{border-bottom:2px solid #e8d5d3}.Pager li>a{border:2px solid #e8d5d3}.Pager li>a:focus,.Pager li>a:hover{background-color:#e8d5d3}.Pager--prev a::before{content:"\2190\00a0"}.Pager--next a::after{content:"\00a0\2192"}.Navbar{height:auto;box-shadow:none}.Navbar .Brand{float:none;line-height:inherit;height:auto}.Homepage{padding-top:10px!important}.Nav__item{font-size:1.309rem}.Nav .Nav .Nav__item a .Nav__arrow:before,.Nav__arrow:before{font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;width:1ch;height:1ch}.TableOfContentsContainer__title{border-bottom:4px solid #e8d5d3}.Columns__right--full .TableOfContentsContainer .TableOfContentsContainer__content>.TableOfContents{border-right:2px solid #e8d5d3}.Columns__right--full .TableOfContentsContainer a{border-bottom:1px solid #e8d5d3}.clients thead tr:first-child th{text-align:left}.clients thead tr:first-child th:first-child{width:15%}.clients thead tr:first-child th:nth-child(3){width:50%;text-align:center}.clients thead tr+tr th{width:16.66%;text-align:center}.clients tbody td:nth-child(3),.clients tbody td:nth-child(4),.clients tbody td:nth-child(5){text-align:center}.clients tbody td.Y{color:#2c9a42}.clients tbody td.N{color:#e63c2f}.hljs,.s-content pre{background:#15284b;color:#e8d5d3}.hljs{display:block;overflow-x:auto;padding:.5em}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-comment,.hljs-quote{color:#978e9c}.hljs-addition,.hljs-keyword,.hljs-selector-tag{color:#acb39a}.hljs-doctag,.hljs-literal,.hljs-meta .hljs-meta-string,.hljs-number,.hljs-regexp,.hljs-string{color:#93b7bb}.hljs-name,.hljs-section,.hljs-selector-class,.hljs-selector-id,.hljs-title{color:#82b7e5}.hljs-attr,.hljs-attribute,.hljs-class .hljs-title,.hljs-template-variable,.hljs-type,.hljs-variable{color:#c5b031}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-meta .hljs-keyword,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-subst,.hljs-symbol{color:#ea8031}.hljs-built_in,.hljs-deletion{color:#e63c2f}.hljs-formula{background:#686986}@media (min-width:850px){.Columns__left{border:0}}
\ No newline at end of file
+html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;font-size:14px}body{margin:0;padding:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block}audio:not([controls]){display:none;height:0}progress,sub,sup{vertical-align:baseline}.s-content pre code:after,.s-content pre code:before,[hidden],template{display:none}a{background-color:transparent;-webkit-text-decoration-skip:objects;text-decoration:none;color:#e63c2f}a:active,a:hover{outline-width:0}abbr[title]{border-bottom:none;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}.s-content blockquote cite,dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;clear:both;margin:1em 0;border:0;border-top:1px solid #ddd}button,input,select,textarea{font:inherit;margin:0}optgroup{font-weight:700}button,hr,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-input-placeholder{color:inherit;opacity:.54}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}*,:after,:before{box-sizing:border-box}@media (min-width:850px){html{font-size:16px}}body,html{height:100%;background-color:#fff;color:#15284b}.Columns__left{background-color:#e8d5d3}.Columns__right__content{padding:10px;background-color:#fff}@media (max-width:768px){html:not(.no-js) .Collapsible__content{height:0;overflow:hidden;transition:height 400ms ease-in-out}}.Collapsible__trigger{margin:12px;padding:7px 10px;background-color:transparent;border:0;float:right;background-image:none;filter:none;box-shadow:none}.Collapsible__trigger__bar{display:block;width:18px;height:2px;margin-top:2px;margin-bottom:3px;background-color:#e8d5d3}.Collapsible__trigger:hover{background-color:#93b7bb;box-shadow:none}.Collapsible__trigger:hover .Collapsible__trigger__bar{background-color:#15284b}@media screen and (min-width:769px){body{background-color:#15284b}.Navbar{position:fixed;z-index:1030;width:100%}.Collapsible__trigger{display:none!important}.Collapsible__content{display:block!important}.Columns{height:100%}.Columns:after,.Columns:before{content:" ";display:table}.Columns:after{clear:both}.Columns__left,.Columns__right{position:relative;min-height:1px;float:left;overflow:auto;height:100%}.Columns__left{width:25%;border-right:1px solid #e7e7e9;overflow-x:hidden}.Columns__right{width:75%}.Columns__right__content{padding:0 20px 20px;min-height:100%}}.Page{max-width:860px}body{font-family:"Cabin","Trebuchet MS",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;font-feature-settings:"kern" 1;-webkit-font-kerning:normal;font-kerning:normal;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;line-height:1.618}h1,h2,h3,h4,h5,h6{font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif}.s-content h1,.s-content h2,.s-content h3,.s-content h4,.s-content h5,.s-content h6{cursor:text;line-height:1.4em;margin:2em 0 .5em}.s-content h1 code,.s-content h1 tt,.s-content h2 code,.s-content h2 tt,.s-content h3 code,.s-content h3 tt,.s-content h4 code,.s-content h4 tt,.s-content h5 code,.s-content h5 tt,.s-content h6 code,.s-content h6 tt{font-size:inherit}.s-content h1 i,.s-content h2 i,.s-content h3 i,.s-content h4 i,.s-content h5 i,.s-content h6 i{font-size:.7em}.s-content h1 p,.s-content h2 p,.s-content h3 p,.s-content h4 p,.s-content h5 p,.s-content h6 p{margin-top:0}.s-content h1{margin-top:0;font-size:2.618rem}.s-content h2{font-size:2rem}.s-content h3{font-size:1.618rem}.s-content h4,.s-content h5,.s-content h6,.s-content small{font-size:1.309rem}.s-content a{text-decoration:underline}.s-content p{margin-bottom:1.3em}.s-content ol,.s-content ul{padding-left:2em}.s-content ul p,.s-content ul ul{margin:0}.s-content dl{padding:0}.s-content dl dt{font-weight:700;font-style:italic;padding:0;margin:15px 0 5px}.s-content dl dt:first-child{padding:0}.s-content dl dd{margin:0 0 15px;padding:0 15px}.s-content blockquote{margin:.75em 2em;padding:.5em 1em;font-style:italic;border-left:.25em solid #15284b}.s-content blockquote cite:before{content:"\2014";padding-right:.5em}.s-content table{width:100%;padding:0;margin-bottom:1em;border-collapse:separate;border-spacing:2px;border:2px solid #b3aab1}.s-content table+table{margin-top:1em}.s-content table tr{background-color:#fff;margin:0;padding:0;border-top:0}.s-content table tr:nth-child(2n){background-color:transparent}.s-content table th{font-weight:700;background:#e8d5d3}.s-content table td,.s-content table th{margin:0;padding:.5em}.s-content blockquote>:first-child,.s-content dl dd>:first-child,.s-content dl dt>:first-child,.s-content ol>:first-child,.s-content table td>:first-child,.s-content table th>:first-child,.s-content ul>:first-child{margin-top:0}.s-content blockquote>:last-child,.s-content dl dd>:last-child,.s-content dl dt>:last-child,.s-content ol>:last-child,.s-content table td>:last-child,.s-content table th>:last-child,.s-content ul>:last-child{margin-bottom:0}.s-content img{max-width:100%;display:block;margin:0 auto}.s-content code{font-family:Monaco,Menlo,Consolas,"Lucida Console","Courier New",monospace;padding-top:.1rem;padding-bottom:.1rem;background:#f9f5f4;border-radius:0;box-shadow:none;display:inline-block;padding:.5ch;border:0}.s-content code:after,.s-content code:before{letter-spacing:-.2em;content:"\00a0"}.s-content pre{background:#f5f2f0;line-height:1.5em;overflow:auto;border:0;border-radius:0;padding:.75em 20px;margin:0 -20px 20px}.s-content pre code{margin:0;padding:0;white-space:pre;box-shadow:none}.s-content pre code,.s-content pre tt{background-color:transparent;border:0}.s-content ins,.s-content u{text-decoration:none;border-bottom:1px solid #15284b}.s-content del a,.s-content ins a,.s-content u a{color:inherit}a.Link--external:after{content:" " url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAVklEQVR4Xn3PgQkAMQhDUXfqTu7kTtkpd5RA8AInfArtQ2iRXFWT2QedAfttj2FsPIOE1eCOlEuoWWjgzYaB/IkeGOrxXhqB+uA9Bfcm0lAZuh+YIeAD+cAqSz4kCMUAAAAASUVORK5CYII=)}a.Link--broken{color:red}p{margin:0 0 1em}.Button{display:inline-block;text-align:center;vertical-align:middle;touch-action:manipulation;cursor:pointer;background-image:none;border:1px solid transparent;white-space:nowrap;margin-bottom:0}.Button--small{font-size:12px;line-height:1.5;border-radius:3px}.Button--default{color:#333;background-color:#fff;border-color:#ccc}.Button--default.Button--active{color:#333;background-color:#e6e6e6;border-color:#adadad}.Brand,.Nav__item a:hover{color:#15284b;text-shadow:none}.Brand,.Navbar{background-color:#e63c2f}.Brand{display:block;padding:.75em .6em;font-size:2rem;font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif}.Navbar{box-shadow:0 1px 5px rgba(0,0,0,.25);margin-bottom:0}.CodeToggler{padding:0 20px}.CodeToggler__text{font-size:12px;line-height:1.5;padding:6px 10px 6px 0;display:inline-block;vertical-align:middle}.no-js .CodeToggler{display:none}.Nav{margin:0;padding:0}.Nav__arrow{display:inline-block;position:relative;width:16px;margin-left:-16px}.Nav__arrow:before{position:absolute;display:block;content:"";margin:-.25em 0 0 -.4em;left:50%;top:50%;border-right:.15em solid #15284b;border-top:.15em solid #15284b;transform:rotate(45deg);transition-duration:.3s}.Nav__item,.Nav__item a{display:block}.Nav__item a{margin:0;padding:6px 15px 6px 20px;font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;font-weight:400;color:#15284b;text-shadow:none}.Nav__item a:hover{background-color:#93b7bb}.Nav .Nav{margin-left:15px}html:not(.no-js) .Nav .Nav{height:0;transition:height 400ms ease-in-out;overflow:hidden}.Nav .Nav .Nav__item a{margin:0 0 0 -15px;padding:3px 30px;font-family:"Cabin","Trebuchet MS",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;color:#15284b;opacity:.7}.HomepageButtons .Button--hero:hover,.Nav .Nav .Nav__item a:hover{opacity:1}.Nav .Nav .Nav__item--active a{color:#15284b}.Nav__item--active>a,.Nav__item--open>a{background-color:#93b7bb}.Nav__item--open>a>.Nav__arrow:before{margin-left:-.25em;transform:rotate(135deg)}.Page__header{margin:0 0 10px;padding:0}.Page__header:after,.Page__header:before{content:" ";display:table}.Page__header:after{clear:both}.Page__header h1{margin:0;padding:0;line-height:57px}.Page__header--separator{height:.6em}.Page__header a{text-decoration:none}.Page__header .EditOn,.Page__header .ModifiedDate{float:left;font-size:10px;color:gray}.Page__header .EditOn{float:right}.Links,.Twitter{padding:0 20px}.Links a{font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;font-weight:400;color:#15284b;line-height:2em}.Twitter{font:11px/18px "Helvetica Neue",Arial,sans-serif}.Twitter__button{text-decoration:none;display:inline-block;vertical-align:top;zoom:1;position:relative;height:20px;box-sizing:border-box;padding:1px 8px 1px 6px;background-color:#1b95e0;color:#fff;border-radius:3px;font-weight:500;cursor:pointer}.Twitter__button .Twitter__button__label{display:inline-block;vertical-align:top;zoom:1;margin-left:3px;white-space:nowrap}.Twitter__button svg{position:relative;top:2px;display:inline-block;width:14px;height:14px}.PoweredBy{padding:0 20px 1rem;font-size:1.309rem}.Search{position:relative}.Search__field{display:block;width:100%;height:34px;padding:6px 30px 6px 20px;color:#555;border-width:0 0 1px;border-bottom:1px solid #ccc;background:#fff;transition:border-color ease-in-out .15s}.Search__field:focus{border-color:#93b7bb;outline:0}.Search__icon{position:absolute;right:9px;top:9px;width:16px;height:16px}.Navbar .Search{float:right;margin:8px 20px}.Navbar .Search__field{box-shadow:inset 0 1px 1px rgba(0,0,0,.075);border-width:0;border-radius:4px;padding-left:10px}.TableOfContentsContainer{float:right;min-width:300px;max-width:25%;padding-left:1em}.TableOfContentsContainer__title{margin-bottom:0!important}.TableOfContentsContainer__content{border:1px solid #efefef;border-width:4px 2px 2px 6px}.TableOfContentsContainer__content>.TableOfContents>li+li{border-top:1px solid #ddd}ul.TableOfContents{font-size:1rem;padding-left:0;margin:0;list-style-type:none;border-left:6px solid #e8d5d3}ul.TableOfContents p{margin-bottom:0}ul.TableOfContents a{text-decoration:none;display:block;padding:.2em 0 .2em .75em}ul.TableOfContents .TableOfContents{padding-left:.75em}.Pager{padding-left:0;margin:1em 0;list-style:none;text-align:center}.Pager:after,.Pager:before{content:" ";display:table}.Pager,.Pager:after{clear:both}.Pager li{display:inline}.Pager li>a{display:inline-block;padding:5px 14px;background-color:#fff}.Pager li>a:focus,.Pager li>a:hover{text-decoration:none}.Pager--next>a{float:right}.Pager--prev>a{float:left}.Checkbox{position:relative;display:block;padding-left:30px;cursor:pointer}.Checkbox input{position:absolute;z-index:-1;opacity:0}.Checkbox__indicator{position:absolute;top:50%;left:0;width:20px;height:20px;margin-top:-10px;background:#e6e6e6}.Checkbox__indicator:after{position:absolute;display:none;content:""}.Checkbox input:focus~.Checkbox__indicator,.Checkbox:hover input~.Checkbox__indicator{background:#ccc}.Checkbox input:checked~.Checkbox__indicator{background:#15284b}.Checkbox input:checked~.Checkbox__indicator:after{display:block}.Checkbox input:checked:focus~.Checkbox__indicator,.Checkbox:hover input:not([disabled]):checked~.Checkbox__indicator{background:#93b7bb}.Checkbox input:disabled~.Checkbox__indicator{pointer-events:none;opacity:.6;background:#e6e6e6}.Checkbox .Checkbox__indicator:after{top:4px;left:8px;width:5px;height:10px;transform:rotate(45deg);border:solid #fff;border-width:0 2px 2px 0}.Checkbox input:disabled~.Checkbox__indicator:after{border-color:#7b7b7b}.Hidden{display:none}.Container{margin-right:auto;margin-left:auto}.Container--inner{width:80%;margin:0 auto}@media (min-width:1200px){.Container{width:1170px}}@media (min-width:992px){.Container{width:970px}}@media (min-width:769px){.Container{width:750px}}.Homepage{background-color:#fff;border-radius:0;border:0;color:#15284b;overflow:hidden;padding-bottom:0;margin-bottom:0;box-shadow:none}.HomepageTitle h2{width:80%;font-size:30px;margin:20px auto;text-align:center}.HomepageImage img{display:block;max-width:80%;margin:0 auto;height:auto}.HomepageButtons{padding:20px 0;background-color:#e8d5d3;text-align:center}.HomepageButtons:after,.HomepageButtons:before{content:" ";display:table}.HomepageButtons:after{clear:both}.HomepageButtons .Button--hero{padding:20px 30px;border-radius:0;text-shadow:none;opacity:.8;margin:0 10px;text-transform:uppercase;border:5px solid #15284b;font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;background-image:none;filter:none;box-shadow:none}@media (max-width:768px){.HomepageButtons .Button--hero{display:block;margin-bottom:10px}}.HomepageButtons .Button--hero.Button--secondary{background-color:#93b7bb;color:#15284b}.HomepageButtons .Button--hero.Button--primary{background-color:#15284b;color:#e8d5d3}.HomepageContent{background-color:#fff;padding:40px 0}.HomepageContent ol li,.HomepageContent ul li{list-style:none;margin-bottom:.5em;position:relative}.HomepageContent ol li:before,.HomepageContent ul li:before{position:absolute;top:50%;left:-1.5em;content:"";width:0;height:0;border:.5em solid transparent;border-left:.5em solid #93b7bb;float:left;display:block;margin-top:-.5em}.HomepageContent .HeroText,.HomepageFooter__links li a{font-size:16px;font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif}.HomepageContent .HeroText{font-weight:300;margin-bottom:20px;line-height:1.4}@media (min-width:769px){.HomepageContent{padding:40px 20px}.HomepageContent .HeroText{font-size:21px}.HomepageContent .Row{margin:0 -15px}.HomepageContent .Row__half,.HomepageContent .Row__quarter,.HomepageContent .Row__third{float:left;position:relative;min-height:1px;padding-left:15px;padding-right:15px}.HomepageContent .Row__third{width:33.333333%}.HomepageContent .Row__half{width:50%}.HomepageContent .Row__quarter{width:25%}}.HomepageFooter{background-color:#15284b;color:#93b7bb;border:0;box-shadow:none}.HomepageFooter:after,.HomepageFooter:before{content:" ";display:table}.HomepageFooter:after{clear:both}@media (max-width:768px){.HomepageFooter{padding:0 20px;text-align:center}.HomepageFooter .HomepageFooter__links{padding-left:0;list-style-type:none}}@media (min-width:769px){.HomepageFooter .HomepageFooter__links{float:left}.HomepageFooter .HomepageFooter__twitter{float:right}}.HomepageFooter__links,.HomepageFooter__twitter{margin:40px 0}.HomepageFooter__links li a{line-height:32px;font-weight:700}.HomepageFooter__links li a:hover{text-decoration:underline}.HomepageFooter .Twitter__button{margin-bottom:20px}@media print{*{text-shadow:none!important;color:#000!important;background:0 0!important;box-shadow:none!important}h1,h2,h3,h4,h5,h6{page-break-after:avoid;page-break-before:auto}blockquote,img,pre{page-break-inside:avoid}blockquote,pre{border:1px solid #999;font-style:italic}img{border:0}a,a:visited{text-decoration:underline}abbr[title]:after{content:" (" attr(title) ")"}q{quotes:none}.s-content a[href^="#"]:after,q:before{content:""}q:after{content:" (" attr(cite) ")"}.PageBreak{display:block;page-break-before:always}.NoPrint,.Pager,aside{display:none}.Columns__right{width:100%!important}.s-content a:after{content:" (" attr(href) ")";font-size:80%;word-wrap:break-word}h1 a[href]:after{font-size:50%}}@font-face{font-family:'League Gothic';src:url(fonts/leaguegothic.woff2) format('woff2'),url(fonts/leaguegothic.woff) format('woff');font-style:normal;font-display:swap}@font-face{font-family:'Cabin';src:url(fonts/cabin-regular.woff2) format('woff2'),url(fonts/cabin-regular.woff) format('woff');font-weight:400;font-style:normal;font-display:swap}@font-face{font-family:'Cabin';src:url(fonts/cabin-italic.woff2) format('woff2'),url(fonts/cabin-italic.woff) format('woff');font-style:italic;font-display:swap}@font-face{font-family:'Cabin';src:url(fonts/cabin-bold.woff2) format('woff2'),url(fonts/cabin-bold.woff) format('woff');font-weight:700;font-style:normal;font-display:swap}.s-content code::after,.s-content code::before,a.Link--external::after{content:''}pre .s-content code{display:inline}.s-content table tbody,.s-content table thead{background-color:#fff}.s-content table tr:nth-child(2n) td{background-color:#f9f5f4}.s-content table td,.s-content table th{border:0}.Nav__item .Nav__item,.s-content table{font-size:1rem}.Brand,h1,h2,h3,h4,h5,h6{font-weight:400}.Button,.Pager li>a{border-radius:0}.HomepageButtons .Button--hero{font-weight:400;font-size:1.309rem}.Page__header{border-bottom:2px solid #e8d5d3}.Pager li>a{border:2px solid #e8d5d3}.Pager li>a:focus,.Pager li>a:hover{background-color:#e8d5d3}.Pager--prev a::before{content:"\2190\00a0"}.Pager--next a::after{content:"\00a0\2192"}.Navbar{height:auto;box-shadow:none}.Navbar .Brand{float:none;line-height:inherit;height:auto}.Homepage{padding-top:10px!important}.Nav__item{font-size:1.309rem}.Nav .Nav .Nav__item a .Nav__arrow:before,.Nav__arrow:before{font-family:"League Gothic",-apple-system,".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande",Arial,sans-serif;width:1ch;height:1ch}.TableOfContentsContainer__title{border-bottom:4px solid #e8d5d3}.Columns__right--full .TableOfContentsContainer .TableOfContentsContainer__content>.TableOfContents{border-right:2px solid #e8d5d3}.Columns__right--full .TableOfContentsContainer a{border-bottom:1px solid #e8d5d3}.clients thead tr:first-child th{text-align:left}.clients thead tr:first-child th:first-child{width:15%}.clients thead tr:first-child th:nth-child(3){width:50%;text-align:center}.clients thead tr+tr th{width:12%;text-align:center}.clients tbody td:nth-child(3),.clients tbody td:nth-child(4),.clients tbody td:nth-child(5),.clients tbody td:nth-child(6){text-align:center}.clients tbody td.Y{color:#2c9a42}.clients tbody td.N{color:#e63c2f}.hljs,.s-content pre{background:#15284b;color:#e8d5d3}.hljs{display:block;overflow-x:auto;padding:.5em}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-comment,.hljs-quote{color:#978e9c}.hljs-addition,.hljs-keyword,.hljs-selector-tag{color:#acb39a}.hljs-doctag,.hljs-literal,.hljs-meta .hljs-meta-string,.hljs-number,.hljs-regexp,.hljs-string{color:#93b7bb}.hljs-name,.hljs-section,.hljs-selector-class,.hljs-selector-id,.hljs-title{color:#82b7e5}.hljs-attr,.hljs-attribute,.hljs-class .hljs-title,.hljs-template-variable,.hljs-type,.hljs-variable{color:#c5b031}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-meta .hljs-keyword,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-subst,.hljs-symbol{color:#ea8031}.hljs-built_in,.hljs-deletion{color:#e63c2f}.hljs-formula{background:#686986}@media (min-width:850px){.Columns__left{border:0}}
\ No newline at end of file
diff --git a/docs/theme/src/arsse.scss b/docs/theme/src/arsse.scss
index 43a26c19..6f5d9d83 100644
--- a/docs/theme/src/arsse.scss
+++ b/docs/theme/src/arsse.scss
@@ -245,12 +245,12 @@ ul.TableOfContents {
}
thead tr + tr th {
- width: 16.66%;
+ width: 12%;
text-align: center;
}
tbody td {
- &:nth-child(3), &:nth-child(4), &:nth-child(5) {
+ &:nth-child(3), &:nth-child(4), &:nth-child(5), &:nth-child(6) {
text-align: center;
}
diff --git a/lib/AbstractException.php b/lib/AbstractException.php
index 706465e0..cde92dca 100644
--- a/lib/AbstractException.php
+++ b/lib/AbstractException.php
@@ -14,6 +14,7 @@ abstract class AbstractException extends \Exception {
"Exception.arrayEmpty" => 10002,
"ExceptionType.strictFailure" => 10011,
"ExceptionType.typeUnknown" => 10012,
+ "Exception.extMissing" => 10021,
"Lang/Exception.defaultFileMissing" => 10101,
"Lang/Exception.fileMissing" => 10102,
"Lang/Exception.fileUnreadable" => 10103,
@@ -46,6 +47,7 @@ abstract class AbstractException extends \Exception {
"Db/Exception.savepointStale" => 10227,
"Db/Exception.resultReused" => 10228,
"Db/ExceptionRetry.schemaChange" => 10229,
+ "Db/ExceptionInput.invalidValue" => 10230,
"Db/ExceptionInput.missing" => 10231,
"Db/ExceptionInput.whitespace" => 10232,
"Db/ExceptionInput.tooLong" => 10233,
@@ -68,13 +70,15 @@ abstract class AbstractException extends \Exception {
"Conf/Exception.typeMismatch" => 10311,
"Conf/Exception.semanticMismatch" => 10312,
"Conf/Exception.ambiguousDefault" => 10313,
- "User/Exception.functionNotImplemented" => 10401,
- "User/Exception.doesNotExist" => 10402,
- "User/Exception.alreadyExists" => 10403,
"User/Exception.authMissing" => 10411,
"User/Exception.authFailed" => 10412,
- "User/ExceptionAuthz.notAuthorized" => 10421,
+ "User/ExceptionConflict.doesNotExist" => 10402,
+ "User/ExceptionConflict.alreadyExists" => 10403,
"User/ExceptionSession.invalid" => 10431,
+ "User/ExceptionInput.invalidTimezone" => 10441,
+ "User/ExceptionInput.invalidValue" => 10442,
+ "User/ExceptionInput.invalidNonZeroInteger" => 10443,
+ "User/ExceptionInput.invalidUsername" => 10444,
"Feed/Exception.internalError" => 10500,
"Feed/Exception.invalidCertificate" => 10501,
"Feed/Exception.invalidUrl" => 10502,
@@ -98,9 +102,27 @@ abstract class AbstractException extends \Exception {
"ImportExport/Exception.invalidFolderName" => 10613,
"ImportExport/Exception.invalidFolderCopy" => 10614,
"ImportExport/Exception.invalidTagName" => 10615,
+ "Rule/Exception.invalidPattern" => 10701,
+ "Service/Exception.pidNotFile" => 10801,
+ "Service/Exception.pidDirMissing" => 10802,
+ "Service/Exception.pidDirUnresolvable" => 10803,
+ "Service/Exception.pidUnusable" => 10804,
+ "Service/Exception.pidUnreadable" => 10805,
+ "Service/Exception.pidUnwritable" => 10806,
+ "Service/Exception.pidUncreatable" => 10807,
+ "Service/Exception.pidCorrupt" => 10808,
+ "Service/Exception.pidDuplicate" => 10809,
+ "Service/Exception.pidLocked" => 10810,
+ "Service/Exception.pidInaccessible" => 10811,
+ "Service/Exception.forkFailed" => 10812,
];
+ protected $symbol;
+ protected $params;
+
public function __construct(string $msgID = "", $vars = null, \Throwable $e = null) {
+ $this->symbol = $msgID;
+ $this->params = $vars ?? [];
if ($msgID === "") {
$msg = "Exception.unknown";
$code = 10000;
@@ -117,4 +139,12 @@ abstract class AbstractException extends \Exception {
}
parent::__construct($msg, $code, $e);
}
+
+ public function getSymbol(): string {
+ return $this->symbol;
+ }
+
+ public function getParams(): array {
+ return $this->params;
+ }
}
diff --git a/lib/Arsse.php b/lib/Arsse.php
index 7d53a427..9f826d0f 100644
--- a/lib/Arsse.php
+++ b/lib/Arsse.php
@@ -7,8 +7,19 @@ declare(strict_types=1);
namespace JKingWeb\Arsse;
class Arsse {
- public const VERSION = "0.8.5";
+ public const VERSION = "0.10.0";
+ public const REQUIRED_EXTENSIONS = [
+ "intl", // as this extension is required to prepare formatted messages, its absence will throw a distinct English-only exception
+ "dom",
+ "filter",
+ "json", // part of the PHP core since version 8.0
+ "hash", // part of the PHP core since version 7.4
+ "simplexml", // required by PicoFeed only
+ "iconv", // required by PicoFeed only
+ ];
+ /** @var Factory */
+ public static $obj;
/** @var Lang */
public static $lang;
/** @var Conf */
@@ -18,11 +29,33 @@ class Arsse {
/** @var User */
public static $user;
+ /** @codeCoverageIgnore */
+ public static function bootstrap(): void {
+ $conf = file_exists(BASE."config.php") ? new Conf(BASE."config.php") : new Conf;
+ static::load($conf);
+ }
+
public static function load(Conf $conf): void {
+ static::$obj = static::$obj ?? new Factory;
static::$lang = static::$lang ?? new Lang;
static::$conf = $conf;
static::$lang->set($conf->lang);
static::$db = static::$db ?? new Database;
static::$user = static::$user ?? new User;
}
+
+ /** Checks whether the specified extensions are loaded and throws an exception if any are not */
+ public static function checkExtensions(string ...$ext): void {
+ $missing = [];
+ foreach ($ext as $e) {
+ if (!extension_loaded($e)) {
+ $missing[] = $e;
+ }
+ }
+ if ($missing) {
+ $total = sizeof($missing);
+ $first = $missing[0];
+ throw new Exception("extMissing", ['first' => $first, 'total' => $total]);
+ }
+ }
}
diff --git a/lib/CLI.php b/lib/CLI.php
index c9a59673..9e9993e3 100644
--- a/lib/CLI.php
+++ b/lib/CLI.php
@@ -8,126 +8,40 @@ namespace JKingWeb\Arsse;
use JKingWeb\Arsse\REST\Fever\User as Fever;
use JKingWeb\Arsse\ImportExport\OPML;
+use JKingWeb\Arsse\REST\Miniflux\Token as Miniflux;
+use JKingWeb\Arsse\Service\Daemon;
class CLI {
public const USAGE = << [] [--admin]
+ arsse.php user remove
+ arsse.php user show
+ arsse.php user set
+ arsse.php user unset
+ arsse.php user set-pass [] [--fever]
+ arsse.php user unset-pass [--fever]
+ arsse.php user auth [--fever]
+ arsse.php token list
+ arsse.php token create []
+ arsse.php token revoke []
+ arsse.php import [] [-f|--flat] [-r|--replace]
+ arsse.php export [] [-f|--flat]
+ arsse.php daemon [--fork=PIDFILE]
arsse.php feed refresh-all
arsse.php feed refresh
arsse.php conf save-defaults []
- arsse.php user [list]
- arsse.php user add []
- arsse.php user remove
- arsse.php user set-pass []
- [--oldpass=] [--fever]
- arsse.php user unset-pass
- [--oldpass=] [--fever]
- arsse.php user auth [--fever]
- arsse.php import []
- [-f | --flat] [-r | --replace]
- arsse.php export []
- [-f | --flat]
arsse.php --version
- arsse.php -h | --help
+ arsse.php -h|--help
The Arsse command-line interface can be used to perform various administrative
tasks such as starting the newsfeed refresh service, managing users, and
importing or exporting data.
-Commands:
+See the manual page for more details:
- daemon
-
- Starts the newsfeed refreshing service, which will refresh stale feeds at
- the configured interval automatically.
-
- feed refresh-all
-
- Refreshes any stale feeds once, then exits. This performs the same
- function as the daemon command without looping; this is useful if use of
- a scheduler such a cron is preferred over a persitent service.
-
- feed refresh
-
- Refreshes a single feed by numeric ID. This is principally for internal
- use as the feed ID numbers are not usually exposed to the user.
-
- conf save-defaults []
-
- Prints default configuration parameters to standard output, or to
- if specified. Each parameter is annotated with a short description of its
- purpose and usage.
-
- user [list]
-
- Prints a list of all existing users, one per line.
-
- user add []
-
- Adds the user specified by , with the provided password
- . If no password is specified, a random password will be
- generated and printed to standard output.
-
- user remove
-
- Removes the user specified by . Data related to the user,
- including folders and subscriptions, are immediately deleted. Feeds to
- which the user was subscribed will be retained and refreshed until the
- configured retention time elapses.
-
- user set-pass []
-
- Changes 's password to . If no password is specified,
- a random password will be generated and printed to standard output.
-
- The --oldpass= option can be used to supply a user's exiting
- password if this is required by the authentication driver to change a
- password. Currently this is not used by any existing driver.
-
- The --fever option sets a user's Fever protocol password instead of their
- general password. As Fever requires that passwords be stored insecurely,
- users do not have Fever passwords by default, and logging in to the Fever
- protocol is disabled until a password is set. It is highly recommended
- that a user's Fever password be different from their general password.
-
- user unset-pass
-
- Unsets a user's password, effectively disabling their account. As with
- password setting, the --oldpass and --fever options may be used.
-
- user auth
-
- Tests logging in as with password . This only checks
- that the user's password is correctly recognized; it has no side effects.
-
- The --fever option may be used to test the user's Fever protocol password,
- if any.
-
- import []
-
- Imports the feeds, folders, and tags found in the OPML formatted
- into the account of . If no file is specified, data is instead
- read from standard input.
-
- The --replace option interprets the OPML file as the list of all desired
- feeds, folders and tags, performing any deletion or moving of existing
- entries which do not appear in the flle. If this option is not specified,
- the file is assumed to list desired additions only.
-
- The --flat option can be used to ignore any folder structures in the file,
- importing any feeds only into the root folder.
-
- export []
-
- Exports 's feeds, folders, and tags to the OPML file specified
- by , or standard output if none is provided. Note that due to a
- limitation of the OPML format, any commas present in tag names will not be
- retained in the export.
-
- The --flat option can be used to omit folders from the export. Some OPML
- implementations may not support folders, or arbitrary nesting; this option
- may be used when planning to import into such software.
+ man arsse
USAGE_TEXT;
protected function usage($prog): string {
@@ -135,22 +49,19 @@ USAGE_TEXT;
return str_replace("arsse.php", $prog, self::USAGE);
}
- protected function command(array $options, $args): string {
- foreach ($options as $cmd) {
- foreach (explode(" ", $cmd) as $part) {
- if (!$args[$part]) {
- continue 2;
- }
+ protected function command($args): string {
+ $out = [];
+ foreach ($args as $k => $v) {
+ if (preg_match("/^[a-z]/", $k) && $v === true) {
+ $out[] = $k;
}
- return $cmd;
}
- return "";
+ return implode(" ", $out);
}
/** @codeCoverageIgnore */
protected function loadConf(): bool {
- $conf = file_exists(BASE."config.php") ? new Conf(BASE."config.php") : new Conf;
- Arsse::load($conf);
+ Arsse::bootstrap();
return true;
}
@@ -168,40 +79,95 @@ USAGE_TEXT;
'help' => false,
]);
try {
- $cmd = $this->command(["-h", "--help", "--version", "daemon", "feed refresh", "feed refresh-all", "conf save-defaults", "user", "export", "import"], $args);
- if ($cmd && !in_array($cmd, ["-h", "--help", "--version", "conf save-defaults"])) {
- // only certain commands don't require configuration to be loaded
+ // ensure the require extensions are loaded
+ Arsse::checkExtensions(...Arsse::REQUIRED_EXTENSIONS);
+ // reconstitute multi-token commands (e.g. user add) into a single string
+ $cmd = $this->command($args);
+ if ($cmd && !in_array($cmd, ["", "conf save-defaults", "daemon"])) {
+ // only certain commands don't require configuration to be loaded; daemon loads configuration after forking (if applicable)
$this->loadConf();
}
+ // run the requested command
switch ($cmd) {
- case "-h":
- case "--help":
- echo $this->usage($argv0).\PHP_EOL;
- return 0;
- case "--version":
- echo Arsse::VERSION.\PHP_EOL;
+ case "":
+ if ($args['--version']) {
+ echo Arsse::VERSION.\PHP_EOL;
+ } elseif ($args['--help'] || $args['-h']) {
+ echo $this->usage($argv0).\PHP_EOL;
+ }
return 0;
case "daemon":
- $this->getInstance(Service::class)->watch(true);
+ if ($args['--fork'] !== null) {
+ return $this->serviceFork($args['--fork']);
+ } else {
+ $this->loadConf();
+ Arsse::$obj->get(Service::class)->watch(true);
+ }
return 0;
case "feed refresh":
return (int) !Arsse::$db->feedUpdate((int) $args[''], true);
case "feed refresh-all":
- $this->getInstance(Service::class)->watch(false);
+ Arsse::$obj->get(Service::class)->watch(false);
return 0;
case "conf save-defaults":
$file = $this->resolveFile($args[''], "w");
- return (int) !$this->getInstance(Conf::class)->exportFile($file, true);
- case "user":
- return $this->userManage($args);
+ return (int) !Arsse::$obj->get(Conf::class)->exportFile($file, true);
case "export":
$u = $args[''];
$file = $this->resolveFile($args[''], "w");
- return (int) !$this->getInstance(OPML::class)->exportFile($file, $u, ($args['--flat'] || $args['-f']));
+ return (int) !Arsse::$obj->get(OPML::class)->exportFile($file, $u, ($args['--flat'] || $args['-f']));
case "import":
$u = $args[''];
$file = $this->resolveFile($args[''], "r");
- return (int) !$this->getInstance(OPML::class)->importFile($file, $u, ($args['--flat'] || $args['-f']), ($args['--replace'] || $args['-r']));
+ return (int) !Arsse::$obj->get(OPML::class)->importFile($file, $u, ($args['--flat'] || $args['-f']), ($args['--replace'] || $args['-r']));
+ case "token list":
+ case "list token": // command reconstruction yields this order for "token list" command
+ return $this->tokenList($args['']);
+ case "token create":
+ echo Arsse::$obj->get(Miniflux::class)->tokenGenerate($args[''], $args['']).\PHP_EOL;
+ return 0;
+ case "token revoke":
+ Arsse::$db->tokenRevoke($args[''], "miniflux.login", $args['']);
+ return 0;
+ case "user add":
+ $out = $this->userAddOrSetPassword("add", $args[""], $args[""]);
+ if ($args['--admin']) {
+ Arsse::$user->propertiesSet($args[""], ['admin' => true]);
+ }
+ return $out;
+ case "user set-pass":
+ if ($args['--fever']) {
+ $passwd = Arsse::$obj->get(Fever::class)->register($args[""], $args[""]);
+ if (is_null($args[""])) {
+ echo $passwd.\PHP_EOL;
+ }
+ return 0;
+ } else {
+ return $this->userAddOrSetPassword("passwordSet", $args[""], $args[""]);
+ }
+ // no break
+ case "user unset-pass":
+ if ($args['--fever']) {
+ Arsse::$obj->get(Fever::class)->unregister($args[""]);
+ } else {
+ Arsse::$user->passwordUnset($args[""]);
+ }
+ return 0;
+ case "user remove":
+ return (int) !Arsse::$user->remove($args[""]);
+ case "user show":
+ return $this->userShowProperties($args[""]);
+ case "user set":
+ return (int) !Arsse::$user->propertiesSet($args[""], [$args[""] => $args[""]]);
+ case "user unset":
+ return (int) !Arsse::$user->propertiesSet($args[""], [$args[""] => null]);
+ case "user auth":
+ return $this->userAuthenticate($args[""], $args[""], $args["--fever"]);
+ case "user list":
+ case "user":
+ return $this->userList();
+ default:
+ throw new Exception("constantUnknown", $cmd); // @codeCoverageIgnore
}
} catch (AbstractException $e) {
$this->logError($e->getMessage());
@@ -214,44 +180,21 @@ USAGE_TEXT;
fwrite(STDERR, $msg.\PHP_EOL);
}
- /** @codeCoverageIgnore */
- protected function getInstance(string $class) {
- return new $class;
- }
-
- protected function userManage($args): int {
- $cmd = $this->command(["add", "remove", "set-pass", "unset-pass", "list", "auth"], $args);
- switch ($cmd) {
- case "add":
- return $this->userAddOrSetPassword("add", $args[""], $args[""]);
- case "set-pass":
- if ($args['--fever']) {
- $passwd = $this->getInstance(Fever::class)->register($args[""], $args[""]);
- if (is_null($args[""])) {
- echo $passwd.\PHP_EOL;
- }
- return 0;
- } else {
- return $this->userAddOrSetPassword("passwordSet", $args[""], $args[""], $args["--oldpass"]);
- }
- // no break
- case "unset-pass":
- if ($args['--fever']) {
- $this->getInstance(Fever::class)->unregister($args[""]);
- } else {
- Arsse::$user->passwordUnset($args[""], $args["--oldpass"]);
- }
- return 0;
- case "remove":
- return (int) !Arsse::$user->remove($args[""]);
- case "auth":
- return $this->userAuthenticate($args[""], $args[""], $args["--fever"]);
- case "list":
- case "":
- return $this->userList();
- default:
- throw new Exception("constantUnknown", $cmd); // @codeCoverageIgnore
- }
+ protected function serviceFork(string $pidfile): int {
+ // initialize the object factory
+ Arsse::$obj = Arsse::$obj ?? new Factory;
+ // create a Daemon object which contains various helper functions
+ $daemon = Arsse::$obj->get(Daemon::class);
+ // resolve the PID file to its absolute path; this also checks its readability and writability
+ $pidfile = $daemon->checkPIDFilePath($pidfile);
+ // daemonize
+ $daemon->fork($pidfile);
+ // start the fetching service as normal
+ $this->loadConf();
+ Arsse::$obj->get(Service::class)->watch(true);
+ // after the service has been shut down, delete the PID file and exit cleanly
+ unlink($pidfile);
+ return 0;
}
protected function userAddOrSetPassword(string $method, string $user, string $password = null, string $oldpass = null): int {
@@ -271,7 +214,7 @@ USAGE_TEXT;
}
protected function userAuthenticate(string $user, string $password, bool $fever = false): int {
- $result = $fever ? $this->getInstance(Fever::class)->authenticate($user, $password) : Arsse::$user->auth($user, $password);
+ $result = $fever ? Arsse::$obj->get(Fever::class)->authenticate($user, $password) : Arsse::$user->auth($user, $password);
if ($result) {
echo Arsse::$lang->msg("CLI.Auth.Success").\PHP_EOL;
return 0;
@@ -280,4 +223,27 @@ USAGE_TEXT;
return 1;
}
}
+
+ protected function userShowProperties(string $user): int {
+ $data = Arsse::$user->propertiesGet($user);
+ $len = array_reduce(array_keys($data), function($carry, $item) {
+ return max($carry, strlen($item));
+ }, 0) + 2;
+ foreach ($data as $k => $v) {
+ echo str_pad($k, $len, " ");
+ echo var_export($v, true).\PHP_EOL;
+ }
+ return 0;
+ }
+
+ protected function tokenList(string $user): int {
+ $list = Arsse::$obj->get(Miniflux::class)->tokenList($user);
+ usort($list, function($v1, $v2) {
+ return $v1['label'] <=> $v2['label'];
+ });
+ foreach ($list as $t) {
+ echo $t['id']." ".$t['label'].\PHP_EOL;
+ }
+ return 0;
+ }
}
diff --git a/lib/Conf.php b/lib/Conf.php
index 2de8addb..428e87a4 100644
--- a/lib/Conf.php
+++ b/lib/Conf.php
@@ -113,14 +113,6 @@ class Conf {
/** @var \DateInterval|null (OBSOLETE) Number of seconds for SQLite to wait before returning a timeout error when trying to acquire a write lock on the database (zero does not wait) */
public $dbSQLite3Timeout = null; // previously 60.0
-
- protected const TYPE_NAMES = [
- Value::T_BOOL => "boolean",
- Value::T_STRING => "string",
- Value::T_FLOAT => "float",
- VALUE::T_INT => "integer",
- Value::T_INTERVAL => "interval",
- ];
protected const EXPECTED_TYPES = [
'dbTimeoutExec' => "double",
'dbTimeoutLock' => "double",
@@ -128,16 +120,14 @@ class Conf {
'dbSQLite3Timeout' => "double",
];
- protected static $types = [];
+ protected $types = [];
/** Creates a new configuration object
* @param string $import_file Optional file to read configuration data from
* @see self::importFile() */
public function __construct(string $import_file = "") {
- if (!static::$types) {
- static::$types = $this->propertyDiscover();
- }
- foreach (array_keys(static::$types) as $prop) {
+ $this->types = $this->propertyDiscover();
+ foreach (array_keys($this->types) as $prop) {
$this->$prop = $this->propertyImport($prop, $this->$prop);
}
if ($import_file !== "") {
@@ -273,9 +263,9 @@ class Conf {
}
protected function propertyImport(string $key, $value, string $file = "") {
- $typeName = static::$types[$key]['name'] ?? "mixed";
- $typeConst = static::$types[$key]['const'] ?? Value::T_MIXED;
- $nullable = (int) (bool) (static::$types[$key]['const'] & Value::M_NULL);
+ $typeName = $this->types[$key]['name'] ?? "mixed";
+ $typeConst = $this->types[$key]['const'] ?? Value::T_MIXED;
+ $nullable = (int) (bool) ($typeConst & Value::M_NULL);
try {
if ($typeName === "\\DateInterval") {
// date intervals have special handling: if the existing value (ultimately, the default value)
@@ -319,8 +309,8 @@ class Conf {
}
return $value;
} catch (ExceptionType $e) {
- $type = static::$types[$key]['const'] & ~(Value::M_STRICT | Value::M_DROP | Value::M_NULL | Value::M_ARRAY);
- throw new Conf\Exception("typeMismatch", ['param' => $key, 'type' => self::TYPE_NAMES[$type], 'file' => $file, 'nullable' => $nullable]);
+ $type = $this->types[$key]['const'] & ~(Value::M_STRICT | Value::M_DROP | Value::M_NULL | Value::M_ARRAY);
+ throw new Conf\Exception("typeMismatch", ['param' => $key, 'type' => Value::TYPE_NAMES[$type], 'file' => $file, 'nullable' => $nullable]);
}
}
diff --git a/lib/Context/Context.php b/lib/Context/Context.php
index fb1236a3..8e1b699c 100644
--- a/lib/Context/Context.php
+++ b/lib/Context/Context.php
@@ -13,6 +13,7 @@ class Context extends ExclusionContext {
public $offset = 0;
public $unread;
public $starred;
+ public $hidden;
public $labelled;
public $annotated;
@@ -46,6 +47,10 @@ class Context extends ExclusionContext {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
+ public function hidden(bool $spec = null) {
+ return $this->act(__FUNCTION__, func_num_args(), $spec);
+ }
+
public function labelled(bool $spec = null) {
return $this->act(__FUNCTION__, func_num_args(), $spec);
}
diff --git a/lib/Database.php b/lib/Database.php
index 48d29863..f3320ce2 100644
--- a/lib/Database.php
+++ b/lib/Database.php
@@ -11,8 +11,10 @@ use JKingWeb\Arsse\Db\Statement;
use JKingWeb\Arsse\Misc\Query;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Misc\Date;
-use JKingWeb\Arsse\Misc\ValueInfo;
+use JKingWeb\Arsse\Misc\ValueInfo as V;
use JKingWeb\Arsse\Misc\URL;
+use JKingWeb\Arsse\Rule\Rule;
+use JKingWeb\Arsse\Rule\Exception as RuleException;
/** The high-level interface with the database
*
@@ -23,6 +25,7 @@ use JKingWeb\Arsse\Misc\URL;
* - Folders, which belong to users and contain subscriptions
* - Tags, which belong to users and can be assigned to multiple subscriptions
* - Feeds to which users are subscribed
+ * - Icons, which are associated with feeds
* - Articles, which belong to feeds and for which users can only affect metadata
* - Editions, identifying authorial modifications to articles
* - Labels, which belong to users and can be assigned to multiple articles
@@ -35,11 +38,14 @@ use JKingWeb\Arsse\Misc\URL;
* deletes a user from the database, and labelArticlesSet() changes a label's
* associations with articles. There has been an effort to keep public method
* names consistent throughout, but protected methods, having different
- * concerns, will typicsally follow different conventions.
+ * concerns, will typically follow different conventions.
+ *
+ * Note that operations on users should be performed with the User class rather
+ * than the Database class directly. This is to allow for alternate user sources.
*/
class Database {
/** The version number of the latest schema the interface is aware of */
- const SCHEMA_VERSION = 7;
+ public const SCHEMA_VERSION = 7;
/** Makes tag/label association change operations remove members */
public const ASSOC_REMOVE = 0;
/** Makes tag/label association change operations add members */
@@ -75,7 +81,11 @@ class Database {
/** Returns the bare name of the calling context's calling method, when __FUNCTION__ is not appropriate */
protected function caller(): string {
- return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function'];
+ $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 4);
+ if ($trace[2]['function'] === "articleQuery") {
+ return $trace[3]['function'];
+ }
+ return $trace[2]['function'];
}
/** Returns the current (actual) schema version of the database; compared against self::SCHEMA_VERSION to know when an upgrade is required */
@@ -145,7 +155,7 @@ class Database {
$count = 0;
$convType = Db\AbstractStatement::TYPE_NORM_MAP[Statement::TYPES[$type]];
foreach ($values as $v) {
- $v = ValueInfo::normalize($v, $convType, null, "sql");
+ $v = V::normalize($v, $convType, null, "sql");
if (is_null($v)) {
// nulls are pointless to have
continue;
@@ -157,7 +167,7 @@ class Database {
$clause[] = $this->db->literalString($v);
}
} else {
- $clause[] = ValueInfo::normalize($v, ValueInfo::T_STRING, null, "sql");
+ $clause[] = V::normalize($v, V::T_STRING, null, "sql");
}
$count++;
}
@@ -238,35 +248,51 @@ class Database {
/** Returns whether the specified user exists in the database */
public function userExists(string $user): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
return (bool) $this->db->prepare("SELECT count(*) from arsse_users where id = ?", "str")->run($user)->getValue();
}
+ /** Returns the username associated with a user number */
+ public function userLookup(int $num): string {
+ $out = $this->db->prepare("SELECT id from arsse_users where num = ?", "int")->run($num)->getValue();
+ if ($out === null) {
+ throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $num]);
+ }
+ return $out;
+ }
+
/** Adds a user to the database
*
* @param string $user The user to add
- * @param string $passwordThe user's password in cleartext. It will be stored hashed
+ * @param string|null $passwordThe user's password in cleartext. It will be stored hashed. If null is provided the user will not be able to log in
*/
- public function userAdd(string $user, string $password): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- } elseif ($this->userExists($user)) {
- throw new User\Exception("alreadyExists", ["action" => __FUNCTION__, "user" => $user]);
+ public function userAdd(string $user, ?string $password): bool {
+ if ($this->userExists($user)) {
+ throw new User\ExceptionConflict("alreadyExists", ["action" => __FUNCTION__, "user" => $user]);
}
$hash = (strlen($password) > 0) ? password_hash($password, \PASSWORD_DEFAULT) : "";
- $this->db->prepare("INSERT INTO arsse_users(id,password) values(?,?)", "str", "str")->runArray([$user,$hash]);
+ // NOTE: This roundabout construction (with 'select' rather than 'values') is required by MySQL, because MySQL is riddled with pitfalls and exceptions
+ $this->db->prepare("INSERT INTO arsse_users(id,password,num) select ?, ?, (coalesce((select max(num) from arsse_users), 0) + 1)", "str", "str")->runArray([$user,$hash]);
+ return true;
+ }
+
+ public function userRename(string $user, string $name): bool {
+ if ($user === $name) {
+ return false;
+ }
+ try {
+ if (!$this->db->prepare("UPDATE arsse_users set id = ? where id = ?", "str", "str")->run($name, $user)->changes()) {
+ throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ }
+ } catch (Db\ExceptionInput $e) {
+ throw new User\ExceptionConflict("alreadyExists", ["action" => __FUNCTION__, "user" => $name], $e);
+ }
return true;
}
/** Removes a user from the database */
public function userRemove(string $user): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
if ($this->db->prepare("DELETE from arsse_users where id = ?", "str")->run($user)->changes() < 1) {
- throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
return true;
}
@@ -274,9 +300,6 @@ class Database {
/** Returns a flat, indexed array of all users in the database */
public function userList(): array {
$out = [];
- if (!Arsse::$user->authorize("", __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => ""]);
- }
foreach ($this->db->query("SELECT id from arsse_users") as $user) {
$out[] = $user['id'];
}
@@ -285,10 +308,8 @@ class Database {
/** Retrieves the hashed password of a user */
public function userPasswordGet(string $user): ?string {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- } elseif (!$this->userExists($user)) {
- throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ if (!$this->userExists($user)) {
+ throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
return $this->db->prepare("SELECT password from arsse_users where id = ?", "str")->run($user)->getValue();
}
@@ -296,25 +317,61 @@ class Database {
/** Sets the password of an existing user
*
* @param string $user The user for whom to set the password
- * @param string $password The new password, in cleartext. The password will be stored hashed. If null is passed, the password is unset and authentication not possible
+ * @param string|null $password The new password, in cleartext. The password will be stored hashed. If null is passed, the password is unset and authentication not possible
*/
- public function userPasswordSet(string $user, string $password = null): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- } elseif (!$this->userExists($user)) {
- throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ public function userPasswordSet(string $user, ?string $password): bool {
+ if (!$this->userExists($user)) {
+ throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
$hash = (strlen($password ?? "") > 0) ? password_hash($password, \PASSWORD_DEFAULT) : $password;
$this->db->prepare("UPDATE arsse_users set password = ? where id = ?", "str", "str")->run($hash, $user);
return true;
}
+ public function userPropertiesGet(string $user, bool $includeLarge = true): array {
+ $basic = $this->db->prepare("SELECT num, admin from arsse_users where id = ?", "str")->run($user)->getRow();
+ if (!$basic) {
+ throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ }
+ $exclude = ["num", "admin"];
+ if (!$includeLarge) {
+ $exclude = array_merge($exclude, User::PROPERTIES_LARGE);
+ }
+ [$inClause, $inTypes, $inValues] = $this->generateIn($exclude, "str");
+ $meta = $this->db->prepare("SELECT \"key\", value from arsse_user_meta where owner = ? and \"key\" not in ($inClause) order by \"key\"", "str", $inTypes)->run($user, $inValues)->getAll();
+ $meta = array_merge($basic, array_combine(array_column($meta, "key"), array_column($meta, "value")));
+ settype($meta['num'], "integer");
+ settype($meta['admin'], "integer");
+ return $meta;
+ }
+
+ public function userPropertiesSet(string $user, array $data): bool {
+ if (!$this->userExists($user)) {
+ throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ }
+ $tr = $this->begin();
+ $find = $this->db->prepare("SELECT count(*) from arsse_user_meta where owner = ? and \"key\" = ?", "str", "strict str");
+ $update = $this->db->prepare("UPDATE arsse_user_meta set value = ?, modified = CURRENT_TIMESTAMP where owner = ? and \"key\" = ?", "str", "str", "str");
+ $insert = $this->db->prepare("INSERT INTO arsse_user_meta(owner, \"key\", value) values(?, ?, ?)", "str", "strict str", "str");
+ foreach ($data as $k => $v) {
+ if ($k === "admin") {
+ $this->db->prepare("UPDATE arsse_users SET admin = ? where id = ?", "bool", "str")->run($v, $user);
+ } elseif ($k === "num") {
+ continue;
+ } else {
+ if ($find->run($user, $k)->getValue()) {
+ $update->run($v, $user, $k);
+ } else {
+ $insert->run($user, $k, $v);
+ }
+ }
+ }
+ $tr->commit();
+ return true;
+ }
+
/** Creates a new session for the given user and returns the session identifier */
public function sessionCreate(string $user): string {
- // If the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- 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);
@@ -333,10 +390,6 @@ class Database {
* @param string|null $id The identifier of the session to destroy
*/
public function sessionDestroy(string $user, string $id = null): bool {
- // If the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
if (is_null($id)) {
// delete all sessions and report success unconditionally if no identifier was specified
$this->db->prepare("DELETE FROM arsse_sessions where \"user\" = ?", "str")->run($user);
@@ -389,13 +442,10 @@ class Database {
* @param string|null $id The value of the token; if none is provided a UUID will be generated
* @param \DateTimeInterface|null $expires An optional expiry date and time for the token
* @param string $data Application-specific data associated with a token
- */
- public function tokenCreate(string $user, string $class, string $id = null, \DateTimeInterface $expires = null, string $data = null): string {
- // If the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- } elseif (!$this->userExists($user)) {
- throw new User\Exception("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ */
+ public function tokenCreate(string $user, string $class, string $id = null, ?\DateTimeInterface $expires = null, string $data = null): string {
+ if (!$this->userExists($user)) {
+ throw new User\ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
// generate a token if it's not provided
$id = $id ?? UUID::mint()->hex;
@@ -411,11 +461,7 @@ class Database {
* @param string $class The class of the token e.g. the protocol name
* @param string|null $id The ID of a specific token, or null for all tokens in the class
*/
- public function tokenRevoke(string $user, string $class, string $id = null): bool {
- // If the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
+ public function tokenRevoke(string $user, string $class, ?string $id = null): bool {
if (is_null($id)) {
$out = $this->db->prepare("DELETE FROM arsse_tokens where \"user\" = ? and class = ?", "str", "str")->run($user, $class)->changes();
} else {
@@ -433,6 +479,11 @@ class Database {
return $out;
}
+ /** List tokens associated with a user */
+ public function tokenList(string $user, string $class): Db\Result {
+ return $this->db->prepare("SELECT id,created,expires,data from arsse_tokens where class = ? and \"user\" = ? and (expires is null or expires > CURRENT_TIMESTAMP)", "str", "str")->run($class, $user);
+ }
+
/** Deletes expires tokens from the database, returning the number of deleted tokens */
public function tokenCleanup(): int {
return $this->db->query("DELETE FROM arsse_tokens where expires < CURRENT_TIMESTAMP")->changes();
@@ -451,10 +502,6 @@ class Database {
* @param array $data An associative array defining the folder
*/
public function folderAdd(string $user, array $data): int {
- // If the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// normalize folder's parent, if there is one
$parent = array_key_exists("parent", $data) ? $this->folderValidateId($user, $data['parent'])['id'] : null;
// validate the folder name and parent (if specified); this also checks for duplicates
@@ -479,10 +526,6 @@ class Database {
* @param boolean $recursive Whether to list all descendents (true) or only direct children (false)
*/
public function folderList(string $user, $parent = null, bool $recursive = true): Db\Result {
- // if the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// check to make sure the parent exists, if one is specified
$parent = $this->folderValidateId($user, $parent)['id'];
$q = new Query(
@@ -515,10 +558,7 @@ class Database {
* @param integer $id The identifier of the folder to delete
*/
public function folderRemove(string $user, $id): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
- if (!ValueInfo::id($id)) {
+ if (!V::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "folder", 'type' => "int > 0"]);
}
$changes = $this->db->prepare("DELETE FROM arsse_folders where owner = ? and id = ?", "str", "int")->run($user, $id)->changes();
@@ -530,10 +570,7 @@ class Database {
/** Returns the identifier, name, and parent of the given folder as an associative array */
public function folderPropertiesGet(string $user, $id): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
- if (!ValueInfo::id($id)) {
+ if (!V::id($id)) {
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 = ? and id = ?", "str", "int")->run($user, $id)->getRow();
@@ -557,9 +594,6 @@ class Database {
* @param array $data An associative array of properties to modify. Anything not specified will remain unchanged
*/
public function folderPropertiesSet(string $user, $id, array $data): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// verify the folder belongs to the user
$in = $this->folderValidateId($user, $id, true);
$name = array_key_exists("name", $data);
@@ -602,7 +636,7 @@ class Database {
*/
protected function folderValidateId(string $user, $id = null, bool $subject = false): array {
// if the specified ID is not a non-negative integer (or null), this will always fail
- if (!ValueInfo::id($id, true)) {
+ if (!V::id($id, true)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "folder", 'type' => "int >= 0"]);
}
// if a null or zero ID is specified this is a no-op
@@ -624,13 +658,13 @@ class Database {
// the root cannot be moved
throw new Db\ExceptionInput("circularDependence", $errData);
}
- $info = ValueInfo::int($parent);
+ $info = V::int($parent);
// the root is always a valid parent
- if ($info & (ValueInfo::NULL | ValueInfo::ZERO)) {
+ if ($info & (V::NULL | V::ZERO)) {
$parent = null;
} else {
// if a negative integer or non-integer is specified this will always fail
- if (!($info & ValueInfo::VALID) || (($info & ValueInfo::NEG))) {
+ if (!($info & V::VALID) || (($info & V::NEG))) {
throw new Db\ExceptionInput("idMissing", $errData);
}
$parent = (int) $parent;
@@ -642,20 +676,27 @@ class Database {
// make sure both that the prospective parent exists, and that the it is not one of its children (a circular dependence);
// also make sure that a folder with the same prospective name and parent does not already exist: if the parent is null,
// SQL will happily accept duplicates (null is not unique), so we must do this check ourselves
- $p = $this->db->prepare(
+ $p = $this->db->prepareArray(
"WITH RECURSIVE
- target as (select ? as userid, ? as source, ? as dest, ? as new_name),
- folders as (SELECT id from arsse_folders join target on owner = userid and coalesce(parent,0) = source union all select arsse_folders.id as id from arsse_folders join folders on arsse_folders.parent=folders.id)
- ".
- "SELECT
- case when ((select dest from target) is null or exists(select id from arsse_folders join target on owner = userid and coalesce(id,0) = coalesce(dest,0))) then 1 else 0 end as extant,
- case when not exists(select id from folders where id = coalesce((select dest from target),0)) then 1 else 0 end as valid,
- case when not exists(select id from arsse_folders join target on coalesce(parent,0) = coalesce(dest,0) and name = coalesce((select new_name from target),(select name from arsse_folders join target on id = source))) then 1 else 0 end as available
- ",
- "str",
- "strict int",
- "int",
- "str"
+ target as (
+ SELECT ? as userid, ? as source, ? as dest, ? as new_name
+ ),
+ folders as (
+ SELECT id from arsse_folders join target on owner = userid and coalesce(parent,0) = source
+ union all
+ select arsse_folders.id as id from arsse_folders join folders on arsse_folders.parent=folders.id
+ )
+ SELECT
+ case when
+ ((select dest from target) is null or exists(select id from arsse_folders join target on owner = userid and coalesce(id,0) = coalesce(dest,0)))
+ then 1 else 0 end as extant,
+ case when
+ not exists(select id from folders where id = coalesce((select dest from target),0))
+ then 1 else 0 end as valid,
+ case when
+ not exists(select id from arsse_folders join target on coalesce(parent,0) = coalesce(dest,0) and name = coalesce((select new_name from target),(select name from arsse_folders join target on id = source)))
+ then 1 else 0 end as available",
+ ["str", "strict int", "int", "str"]
)->run($user, $id, $parent, $name)->getRow();
if (!$p['extant']) {
// if the parent doesn't exist or doesn't below to the user, throw an exception
@@ -677,12 +718,12 @@ class Database {
* @param integer|null $parent The parent folder context in which to check for duplication
*/
protected function folderValidateName($name, bool $checkDuplicates = false, $parent = null): bool {
- $info = ValueInfo::str($name);
- if ($info & (ValueInfo::NULL | ValueInfo::EMPTY)) {
+ $info = V::str($name);
+ if ($info & (V::NULL | V::EMPTY)) {
throw new Db\ExceptionInput("missing", ["action" => $this->caller(), "field" => "name"]);
- } elseif ($info & ValueInfo::WHITE) {
+ } elseif ($info & V::WHITE) {
throw new Db\ExceptionInput("whitespace", ["action" => $this->caller(), "field" => "name"]);
- } elseif (!($info & ValueInfo::VALID)) {
+ } elseif (!($info & V::VALID)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "name", 'type' => "string"]);
} elseif ($checkDuplicates) {
// make sure that a folder with the same prospective name and parent does not already exist: if the parent is null,
@@ -704,18 +745,42 @@ class Database {
* @param string $fetchUser The user name required to access the newsfeed, if applicable
* @param string $fetchPassword The password required to fetch the newsfeed, if applicable; this will be stored in cleartext
* @param boolean $discover Whether to perform newsfeed discovery if $url points to an HTML document
+ * @param boolean $scrape Whether the initial synchronization should scrape full-article content
*/
- public function subscriptionAdd(string $user, string $url, string $fetchUser = "", string $fetchPassword = "", bool $discover = true): int {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
+ public function subscriptionAdd(string $user, string $url, string $fetchUser = "", string $fetchPassword = "", bool $discover = true, bool $scrape = false): int {
// get the ID of the underlying feed, or add it if it's not yet in the database
- $feedID = $this->feedAdd($url, $fetchUser, $fetchPassword, $discover);
+ $feedID = $this->feedAdd($url, $fetchUser, $fetchPassword, $discover, $scrape);
// Add the feed to the user's subscriptions and return the new subscription's ID.
return $this->db->prepare('INSERT INTO arsse_subscriptions(owner,feed) values(?,?)', 'str', 'int')->run($user, $feedID)->lastId();
}
/** Lists a user's subscriptions, returning various data
+ *
+ * Each record has the following keys:
+ *
+ * - "id": The numeric identifier of the subscription
+ * - "feed": The numeric identifier of the underlying newsfeed
+ * - "url": The URL of the newsfeed, after discovery and HTTP redirects
+ * - "title": The title of the newsfeed
+ * - "source": The URL of the source of the newsfeed i.e. its parent Web site
+ * - "icon_id": The numeric identifier of an icon representing the newsfeed or its source
+ * - "icon_url": The URL of an icon representing the newsfeed or its source
+ * - "folder": The numeric identifier (or null) of the subscription's folder
+ * - "top_folder": The numeric identifier (or null) of the top-level folder for the subscription
+ * - "pinned": Whether the subscription is pinned
+ * - "err_count": The count of times attempting to refresh the newsfeed has resulted in an error since the last successful retrieval
+ * - "err_msg": The error message of the last unsuccessful retrieval
+ * - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0)
+ * - "keep_rule": The subscription's "keep" filter rule; articles which do not match this are hidden
+ * - "block_rule": The subscription's "block" filter rule; articles which match this are hidden
+ * - "added": The date and time at which the subscription was added
+ * - "updated": The date and time at which the newsfeed was last updated in the database
+ * - "edited": The date and time at which the newsfeed was last modified by its authors
+ * - "modified": The date and time at which the subscription properties were last changed by the user
+ * - "next_fetch": The date and time and which the feed will next be fetched
+ * - "etag": The ETag header-field in the last fetch response
+ * - "scrape": Whether the user wants scrape full-article content
+ * - "unread": The number of unread articles associated with the subscription
*
* @param string $user The user whose subscriptions are to be listed
* @param integer|null $folder The identifier of the folder under which to list subscriptions; by default the root folder is used
@@ -723,38 +788,54 @@ class Database {
* @param integer|null $id The numeric identifier of a particular subscription; used internally by subscriptionPropertiesGet
*/
public function subscriptionList(string $user, $folder = null, bool $recursive = true, int $id = null): Db\Result {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// validate inputs
$folder = $this->folderValidateId($user, $folder)['id'];
// create a complex query
+ $integer = $this->db->sqlToken("integer");
$q = new Query(
"SELECT
- arsse_subscriptions.id as id,
- arsse_subscriptions.feed as feed,
- url,favicon,source,folder,pinned,err_count,err_msg,order_type,added,
- arsse_feeds.updated as updated,
- arsse_feeds.modified as edited,
- arsse_subscriptions.modified as modified,
- topmost.top as top_folder,
- coalesce(arsse_subscriptions.title, arsse_feeds.title) as title,
- (articles - marked) as unread
- FROM arsse_subscriptions
- left join topmost on topmost.f_id = arsse_subscriptions.folder
- join arsse_feeds on arsse_feeds.id = arsse_subscriptions.feed
- left join (select feed, count(*) as articles from arsse_articles group by feed) as article_stats on article_stats.feed = arsse_subscriptions.feed
- left join (select subscription, sum(\"read\") as marked from arsse_marks group by subscription) as mark_stats on mark_stats.subscription = arsse_subscriptions.id"
+ s.id as id,
+ s.feed as feed,
+ f.url,source,pinned,err_count,err_msg,order_type,added,keep_rule,block_rule,f.etag,s.scrape,
+ f.updated as updated,
+ f.modified as edited,
+ s.modified as modified,
+ f.next_fetch,
+ case when i.data is not null then i.id end as icon_id,
+ i.url as icon_url,
+ folder, t.top as top_folder, d.name as folder_name, dt.name as top_folder_name,
+ coalesce(s.title, f.title) as title,
+ coalesce((articles - hidden - marked), coalesce(articles,0)) as unread
+ FROM arsse_subscriptions as s
+ join arsse_feeds as f on f.id = s.feed
+ left join topmost as t on t.f_id = s.folder
+ left join arsse_folders as d on s.folder = d.id
+ left join arsse_folders as dt on t.top = dt.id
+ left join arsse_icons as i on i.id = f.icon
+ left join (
+ select
+ feed,
+ count(*) as articles
+ from arsse_articles
+ group by feed
+ ) as article_stats on article_stats.feed = s.feed
+ left join (
+ select
+ subscription,
+ sum(hidden) as hidden,
+ sum(cast((\"read\" = 1 and hidden = 0) as $integer)) as marked
+ from arsse_marks group by subscription
+ ) as mark_stats on mark_stats.subscription = s.id"
);
- $q->setWhere("arsse_subscriptions.owner = ?", ["str"], [$user]);
+ $q->setWhere("s.owner = ?", ["str"], [$user]);
$nocase = $this->db->sqlToken("nocase");
- $q->setOrder("pinned desc, coalesce(arsse_subscriptions.title, arsse_feeds.title) collate $nocase");
+ $q->setOrder("pinned desc, coalesce(s.title, f.title) collate $nocase");
// topmost folders belonging to the user
$q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders where owner = ? and parent is null union all select id,top from arsse_folders join topmost on parent=f_id", ["str"], [$user]);
if ($id) {
- // this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder
// if an ID is specified, add a suitable WHERE condition and bindings
- $q->setWhere("arsse_subscriptions.id = ?", "int", $id);
+ // this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder
+ $q->setWhere("s.id = ?", "int", $id);
} 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 all select id from arsse_folders join folders on parent = folder", "int", $folder);
@@ -767,11 +848,12 @@ class Database {
return $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
}
- /** Returns the number of subscriptions in a folder, counting recursively */
+ /** Returns the number of subscriptions in a folder, counting recursively
+ *
+ * @param string $user The user whose subscriptions are to be counted
+ * @param integer|null $folder The identifier of the folder under which to count subscriptions; by default the root folder is used
+ */
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
@@ -794,10 +876,7 @@ class Database {
* configurable retention period for newsfeeds
*/
public function subscriptionRemove(string $user, $id): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
- if (!ValueInfo::id($id)) {
+ if (!V::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'type' => "int > 0"]);
}
$changes = $this->db->prepare("DELETE from arsse_subscriptions where owner = ? and id = ?", "str", "int")->run($user, $id)->changes();
@@ -807,29 +886,9 @@ class Database {
return true;
}
- /** Retrieves data about a particular subscription, as an associative array with the following keys:
- *
- * - "id": The numeric identifier of the subscription
- * - "feed": The numeric identifier of the underlying newsfeed
- * - "url": The URL of the newsfeed, after discovery and HTTP redirects
- * - "title": The title of the newsfeed
- * - "favicon": The URL of an icon representing the newsfeed or its source
- * - "source": The URL of the source of the newsfeed i.e. its parent Web site
- * - "folder": The numeric identifier (or null) of the subscription's folder
- * - "top_folder": The numeric identifier (or null) of the top-level folder for the subscription
- * - "pinned": Whether the subscription is pinned
- * - "err_count": The count of times attempting to refresh the newsfeed has resulted in an error since the last successful retrieval
- * - "err_msg": The error message of the last unsuccessful retrieval
- * - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0)
- * - "added": The date and time at which the subscription was added
- * - "updated": The date and time at which the newsfeed was last updated (not when it was last refreshed)
- * - "unread": The number of unread articles associated with the subscription
- */
+ /** Retrieves data about a particular subscription, as an associative array; see subscriptionList for details */
public function subscriptionPropertiesGet(string $user, $id): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
- if (!ValueInfo::id($id)) {
+ if (!V::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'type' => "int > 0"]);
}
$sub = $this->subscriptionList($user, null, true, (int) $id)->getRow();
@@ -843,44 +902,61 @@ class Database {
*
* The $data array must contain one or more of the following keys:
*
- * - "title": The title of the newsfeed
+ * - "title": The title of the subscription
* - "folder": The numeric identifier (or null) of the subscription's folder
* - "pinned": Whether the subscription is pinned
+ * - "scrape": Whether to scrape full article contents from the HTML article
* - "order_type": Whether articles should be sorted in reverse cronological order (2), chronological order (1), or the default (0)
+ * - "keep_rule": The subscription's "keep" filter rule; articles which do not match this are hidden
+ * - "block_rule": The subscription's "block" filter rule; articles which match this are hidden
*
* @param string $user The user whose subscription is to be modified
* @param integer $id the numeric identifier of the subscription to modfify
* @param array $data An associative array of properties to modify; any keys not specified will be left unchanged
*/
public function subscriptionPropertiesSet(string $user, $id, array $data): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$tr = $this->db->begin();
// validate the ID
- $id = $this->subscriptionValidateId($user, $id, true)['id'];
+ $id = (int) $this->subscriptionValidateId($user, $id, true)['id'];
if (array_key_exists("folder", $data)) {
// ensure the target folder exists and belong to the user
$data['folder'] = $this->folderValidateId($user, $data['folder'])['id'];
}
- if (array_key_exists("title", $data)) {
+ if (isset($data['title'])) {
// if the title is null, this signals intended use of the default title; otherwise make sure it's not effectively an empty string
- if (!is_null($data['title'])) {
- $info = ValueInfo::str($data['title']);
- if ($info & ValueInfo::EMPTY) {
- throw new Db\ExceptionInput("missing", ["action" => __FUNCTION__, "field" => "title"]);
- } elseif ($info & ValueInfo::WHITE) {
- throw new Db\ExceptionInput("whitespace", ["action" => __FUNCTION__, "field" => "title"]);
- } elseif (!($info & ValueInfo::VALID)) {
- throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "title", 'type' => "string"]);
- }
+ $info = V::str($data['title']);
+ if ($info & V::EMPTY) {
+ throw new Db\ExceptionInput("missing", ["action" => __FUNCTION__, "field" => "title"]);
+ } elseif ($info & V::WHITE) {
+ throw new Db\ExceptionInput("whitespace", ["action" => __FUNCTION__, "field" => "title"]);
+ } elseif (!($info & V::VALID)) {
+ throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "title", 'type' => "string"]);
}
}
+ // validate any filter rules
+ if (isset($data['keep_rule'])) {
+ if (!is_string($data['keep_rule'])) {
+ throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "keep_rule", 'type' => "string"]);
+ } elseif (!Rule::validate($data['keep_rule'])) {
+ throw new Db\ExceptionInput("invalidValue", ["action" => __FUNCTION__, "field" => "keep_rule"]);
+ }
+ }
+ if (isset($data['block_rule'])) {
+ if (!is_string($data['block_rule'])) {
+ throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "block_rule", 'type' => "string"]);
+ } elseif (!Rule::validate($data['block_rule'])) {
+ throw new Db\ExceptionInput("invalidValue", ["action" => __FUNCTION__, "field" => "block_rule"]);
+ }
+ }
+ // perform the update
$valid = [
'title' => "str",
'folder' => "int",
'order_type' => "strict int",
'pinned' => "strict bool",
+ 'keep_rule' => "str",
+ 'block_rule' => "str",
+ 'scrape' => "bool",
];
[$setClause, $setTypes, $setValues] = $this->generateSet($data, $valid);
if (!$setClause) {
@@ -889,6 +965,10 @@ class Database {
}
$out = (bool) $this->db->prepare("UPDATE arsse_subscriptions set $setClause, modified = CURRENT_TIMESTAMP where owner = ? and id = ?", $setTypes, "str", "int")->run($setValues, $user, $id)->changes();
$tr->commit();
+ // if filter rules were changed, apply them; this is done outside the transaction because it may take some time
+ if (array_key_exists("keep_rule", $data) || array_key_exists("block_rule", $data)) {
+ $this->subscriptionRulesApply($user, $id);
+ }
return $out;
}
@@ -899,44 +979,45 @@ class Database {
* @param boolean $byName Whether to return the tag names (true) instead of the numeric tag identifiers (false)
*/
public function subscriptionTagsGet(string $user, $id, bool $byName = false): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$this->subscriptionValidateId($user, $id, true);
$field = !$byName ? "id" : "name";
$out = $this->db->prepare("SELECT $field from arsse_tags where id in (select tag from arsse_tag_members where subscription = ? and assigned = 1) order by $field", "int")->run($id)->getAll();
return $out ? array_column($out, $field) : [];
}
- /** Retrieves the URL of the icon for a subscription.
+ /** Retrieves detailed information about the icon for a subscription.
*
- * Note that while the $user parameter is optional, it
- * is NOT recommended to omit it, as this can lead to
- * leaks of private information. The parameter is only
- * optional because this is required for Tiny Tiny RSS,
- * the original implementation of which leaks private
- * information due to a design flaw.
+ * The returned information is:
*
- * @param integer $id The numeric identifier of the subscription
- * @param string|null $user The user who owns the subscription being queried
+ * - "id": The umeric identifier of the icon (not the subscription)
+ * - "url": The URL of the icon
+ * - "type": The Content-Type of the icon e.g. "image/png"
+ * - "data": The icon itself, as a binary sring; if $withData is false this will be null
+ *
+ * If the subscription has no icon null is returned instead of an array
+ *
+ * @param string|null $user The user who owns the subscription being queried; using null here is supported for TT-RSS and SHOULD NOT be used elsewhere as it leaks information
+ * @param int $subscription The numeric identifier of the subscription
+ * @param bool $includeData Whether to include the binary data of the icon itself in the result
*/
- public function subscriptionFavicon(int $id, string $user = null): string {
- $q = new Query("SELECT favicon from arsse_feeds join arsse_subscriptions on feed = arsse_feeds.id");
- $q->setWhere("arsse_subscriptions.id = ?", "int", $id);
+ public function subscriptionIcon(?string $user, int $id, bool $includeData = true): ?array {
+ $data = $includeData ? "i.data" : "null as data";
+ $q = new Query("SELECT i.id, i.url, i.type, $data from arsse_subscriptions as s join arsse_feeds as f on s.feed = f.id left join arsse_icons as i on f.icon = i.id");
+ $q->setWhere("s.id = ?", "int", $id);
if (isset($user)) {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
- $q->setWhere("arsse_subscriptions.owner = ?", "str", $user);
+ $q->setWhere("s.owner = ?", "str", $user);
}
- return (string) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
+ $out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getRow();
+ if (!$out) {
+ throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "subscription", 'id' => $id]);
+ } elseif (!$out['id']) {
+ return null;
+ }
+ return $out;
}
/** Returns the time at which any of a user's subscriptions (or a specific subscription) was last refreshed, as a DateTimeImmutable object */
public function subscriptionRefreshed(string $user, int $id = null): ?\DateTimeImmutable {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$q = new Query("SELECT max(arsse_feeds.updated) from arsse_feeds join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id");
$q->setWhere("arsse_subscriptions.owner = ?", "str", $user);
if ($id) {
@@ -946,7 +1027,49 @@ class Database {
if (!$out && $id) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $id]);
}
- return ValueInfo::normalize($out, ValueInfo::T_DATE | ValueInfo::M_NULL, "sql");
+ return V::normalize($out, V::T_DATE | V::M_NULL, "sql");
+ }
+
+ /** Evalutes the filter rules specified for a subscription against every article associated with the subscription's feed
+ *
+ * @param string $user The user who owns the subscription
+ * @param integer $id The identifier of the subscription whose rules are to be evaluated
+ */
+ protected function subscriptionRulesApply(string $user, int $id): void {
+ // start a transaction for read isolation
+ $tr = $this->begin();
+ $sub = $this->db->prepare("SELECT feed, coalesce(keep_rule, '') as keep, coalesce(block_rule, '') as block from arsse_subscriptions where owner = ? and id = ?", "str", "int")->run($user, $id)->getRow();
+ try {
+ $keep = Rule::prep($sub['keep']);
+ $block = Rule::prep($sub['block']);
+ $feed = $sub['feed'];
+ } catch (RuleException $e) { // @codeCoverageIgnore
+ // invalid rules should not normally appear in the database, but it's possible
+ // in this case we should halt evaluation and just leave things as they are
+ return; // @codeCoverageIgnore
+ }
+ $articles = $this->db->prepare("SELECT id, title, coalesce(categories, 0) as categories from arsse_articles as a left join (select article, count(*) as categories from arsse_categories group by article) as c on a.id = c.article where a.feed = ?", "int")->run($feed)->getAll();
+ $hide = [];
+ $unhide = [];
+ foreach ($articles as $r) {
+ // retrieve the list of categories if the article has any
+ $categories = $r['categories'] ? $this->articleCategoriesGet($user, (int) $r['id']) : [];
+ // evaluate the rule for the article
+ if (Rule::apply($keep, $block, $r['title'], $categories)) {
+ $unhide[] = $r['id'];
+ } else {
+ $hide[] = $r['id'];
+ }
+ }
+ // roll back the read transation
+ $tr->rollback();
+ // apply any marks
+ if ($hide) {
+ $this->articleMark($user, ['hidden' => true], (new Context)->articles($hide), false);
+ }
+ if ($unhide) {
+ $this->articleMark($user, ['hidden' => false], (new Context)->articles($unhide), false);
+ }
}
/** Ensures the specified subscription exists and raises an exception otherwise
@@ -958,7 +1081,7 @@ class Database {
* @param boolean $subject Whether the subscription is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails
*/
protected function subscriptionValidateId(string $user, $id, bool $subject = false): array {
- if (!ValueInfo::id($id)) {
+ if (!V::id($id)) {
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 = ? and owner = ?", "int", "str")->run($id, $user)->getRow();
@@ -976,8 +1099,9 @@ class Database {
* @param string $fetchUser The user name required to access the newsfeed, if applicable
* @param string $fetchPassword The password required to fetch the newsfeed, if applicable; this will be stored in cleartext
* @param boolean $discover Whether to perform newsfeed discovery if $url points to an HTML document
+ * @param boolean $scrape Whether the initial synchronization should scrape full-article content
*/
- public function feedAdd(string $url, string $fetchUser = "", string $fetchPassword = "", bool $discover = true): int {
+ public function feedAdd(string $url, string $fetchUser = "", string $fetchPassword = "", bool $discover = true, bool $scrape = false): int {
// normalize the input URL
$url = URL::normalize($url);
// check to see if the feed already exists
@@ -993,7 +1117,7 @@ class Database {
$feedID = $this->db->prepare('INSERT INTO arsse_feeds(url,username,password) values(?,?,?)', 'str', 'str', 'str')->run($url, $fetchUser, $fetchPassword)->lastId();
try {
// perform an initial update on the newly added feed
- $this->feedUpdate($feedID, true);
+ $this->feedUpdate($feedID, true, $scrape);
} catch (\Throwable $e) {
// if the update fails, delete the feed we just added
$this->db->prepare('DELETE from arsse_feeds where id = ?', 'int')->run($feedID);
@@ -1013,18 +1137,26 @@ class Database {
*
* @param integer $feedID The numerical identifier of the newsfeed to refresh
* @param boolean $throwError Whether to throw an exception on failure in addition to storing error information in the database
+ * @param boolean|null $scrapeOverride If not null, overrides information in the database signaling whether or not to scrape full-article content. This is intended for when there are no subscriptions for the feed in the database yet
*/
- public function feedUpdate($feedID, bool $throwError = false): bool {
+ public function feedUpdate($feedID, bool $throwError = false, ?bool $scrapeOverride = null): bool {
// check to make sure the feed exists
- if (!ValueInfo::id($feedID)) {
+ if (!V::id($feedID)) {
throw new Db\ExceptionInput("typeViolation", ["action" => __FUNCTION__, "field" => "feed", 'id' => $feedID, 'type' => "int > 0"]);
}
- $f = $this->db->prepare("SELECT url, username, password, modified, etag, err_count, scrape FROM arsse_feeds where id = ?", "int")->run($feedID)->getRow();
+ $f = $this->db->prepareArray(
+ "SELECT
+ url, username, password, modified, etag, err_count, scrapers
+ FROM arsse_feeds as f
+ left join (select feed, count(*) as scrapers from arsse_subscriptions where scrape = 1 group by feed) as s on f.id = s.feed
+ where id = ?",
+ ["int"]
+ )->run($feedID)->getRow();
if (!$f) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "feed", 'id' => $feedID]);
}
// determine whether the feed's items should be scraped for full content from the source Web site
- $scrape = (Arsse::$conf->fetchEnableScraping && $f['scrape']);
+ $scrape = (Arsse::$conf->fetchEnableScraping && ($scrapeOverride ?? $f['scrapers']));
// the Feed object throws an exception when there are problems, but that isn't ideal
// here. When an exception is thrown it should update the database with the
// error instead of failing; if other exceptions are thrown, we should simply roll back
@@ -1037,11 +1169,9 @@ class Database {
}
} catch (Feed\Exception $e) {
// update the database with the resultant error and the next fetch time, incrementing the error count
- $this->db->prepare(
+ $this->db->prepareArray(
"UPDATE arsse_feeds SET updated = CURRENT_TIMESTAMP, next_fetch = ?, err_count = err_count + 1, err_msg = ? WHERE id = ?",
- 'datetime',
- 'str',
- 'int'
+ ['datetime', 'str', 'int']
)->run(Feed::nextFetchOnError($f['err_count']), $e->getMessage(), $feedID);
if ($throwError) {
throw $e;
@@ -1055,43 +1185,39 @@ class Database {
$qInsertEdition = $this->db->prepare("INSERT INTO arsse_editions(article) values(?)", 'int');
}
if (sizeof($feed->newItems)) {
- $qInsertArticle = $this->db->prepare(
- "INSERT INTO arsse_articles(url,title,author,published,edited,guid,content,url_title_hash,url_content_hash,title_content_hash,feed) values(?,?,?,?,?,?,?,?,?,?,?)",
- 'str',
- 'str',
- 'str',
- 'datetime',
- 'datetime',
- 'str',
- 'str',
- 'str',
- 'str',
- 'str',
- 'int'
+ $qInsertArticle = $this->db->prepareArray(
+ "INSERT INTO arsse_articles(url,title,author,published,edited,guid,content,url_title_hash,url_content_hash,title_content_hash,feed,content_scraped) values(?,?,?,?,?,?,?,?,?,?,?,?)",
+ ["str", "str", "str", "datetime", "datetime", "str", "str", "str", "str", "str", "int", "str"]
);
}
if (sizeof($feed->changedItems)) {
$qDeleteEnclosures = $this->db->prepare("DELETE FROM arsse_enclosures WHERE article = ?", 'int');
$qDeleteCategories = $this->db->prepare("DELETE FROM arsse_categories WHERE article = ?", 'int');
$qClearReadMarks = $this->db->prepare("UPDATE arsse_marks SET \"read\" = 0, modified = CURRENT_TIMESTAMP WHERE article = ? and \"read\" = 1", 'int');
- $qUpdateArticle = $this->db->prepare(
- "UPDATE arsse_articles SET url = ?, title = ?, author = ?, published = ?, edited = ?, modified = CURRENT_TIMESTAMP, guid = ?, content = ?, url_title_hash = ?, url_content_hash = ?, title_content_hash = ? WHERE id = ?",
- 'str',
- 'str',
- 'str',
- 'datetime',
- 'datetime',
- 'str',
- 'str',
- 'str',
- 'str',
- 'str',
- 'int'
+ $qUpdateArticle = $this->db->prepareArray(
+ "UPDATE arsse_articles SET url = ?, title = ?, author = ?, published = ?, edited = ?, modified = CURRENT_TIMESTAMP, guid = ?, content = ?, url_title_hash = ?, url_content_hash = ?, title_content_hash = ?, content_scraped = ? WHERE id = ?",
+ ["str", "str", "str", "datetime", "datetime", "str", "str", "str", "str", "str", "str", "int"]
);
}
- // actually perform updates
+ // determine if the feed icon needs to be updated, and update it if appropriate
$tr = $this->db->begin();
- foreach ($feed->newItems as $article) {
+ $icon = null;
+ if ($feed->iconUrl) {
+ $icon = $this->db->prepare("SELECT id, url, type, data from arsse_icons where url = ?", "str")->run($feed->iconUrl)->getRow();
+ if ($icon) {
+ // update the existing icon if necessary
+ if ($feed->iconType !== $icon['type'] || $feed->iconData !== $icon['data']) {
+ $this->db->prepare("UPDATE arsse_icons set type = ?, data = ? where id = ?", "str", "blob", "int")->run($feed->iconType, $feed->iconData, $icon['id']);
+ }
+ $icon = $icon['id'];
+ } else {
+ // add the new icon to the cache
+ $icon = $this->db->prepare("INSERT INTO arsse_icons(url, type, data) values(?, ?, ?)", "str", "str", "blob")->run($feed->iconUrl, $feed->iconType, $feed->iconData)->lastId();
+ }
+ }
+ $articleMap = [];
+ // actually perform updates, starting with inserting new articles
+ foreach ($feed->newItems as $k => $article) {
$articleID = $qInsertArticle->run(
$article->url,
$article->title,
@@ -1103,16 +1229,23 @@ class Database {
$article->urlTitleHash,
$article->urlContentHash,
$article->titleContentHash,
- $feedID
+ $feedID,
+ $article->scrapedContent ?? null
)->lastId();
+ // note the new ID for later use
+ $articleMap[$k] = $articleID;
+ // insert any enclosures
if ($article->enclosureUrl) {
$qInsertEnclosure->run($articleID, $article->enclosureUrl, $article->enclosureType);
}
+ // insert any categories
foreach ($article->categories as $c) {
$qInsertCategory->run($articleID, $c);
}
+ // assign a new edition ID to the article
$qInsertEdition->run($articleID);
}
+ // next update existing artricles which have been edited
foreach ($feed->changedItems as $articleID => $article) {
$qUpdateArticle->run(
$article->url,
@@ -1125,8 +1258,10 @@ class Database {
$article->urlTitleHash,
$article->urlContentHash,
$article->titleContentHash,
+ $article->scrapedContent ?? null,
$articleID
);
+ // delete all enclosures and categories and re-insert them
$qDeleteEnclosures->run($articleID);
$qDeleteCategories->run($articleID);
if ($article->enclosureUrl) {
@@ -1135,28 +1270,45 @@ class Database {
foreach ($article->categories as $c) {
$qInsertCategory->run($articleID, $c);
}
+ // assign a new edition ID to this version of the article
$qInsertEdition->run($articleID);
$qClearReadMarks->run($articleID);
}
+ // hide or unhide any filtered articles
+ foreach ($feed->filteredItems as $user => $filterData) {
+ $hide = [];
+ $unhide = [];
+ foreach ($filterData['new'] as $index => $keep) {
+ if (!$keep) {
+ $hide[] = $articleMap[$index];
+ }
+ }
+ foreach ($filterData['changed'] as $article => $keep) {
+ if (!$keep) {
+ $hide[] = $article;
+ } else {
+ $unhide[] = $article;
+ }
+ }
+ if ($hide) {
+ $this->articleMark($user, ['hidden' => true], (new Context)->articles($hide), false);
+ }
+ if ($unhide) {
+ $this->articleMark($user, ['hidden' => false], (new Context)->articles($unhide), false);
+ }
+ }
// lastly update the feed database itself with updated information.
- $this->db->prepare(
- "UPDATE arsse_feeds SET title = ?, favicon = ?, source = ?, updated = CURRENT_TIMESTAMP, modified = ?, etag = ?, err_count = 0, err_msg = '', next_fetch = ?, size = ? WHERE id = ?",
- 'str',
- 'str',
- 'str',
- 'datetime',
- 'strict str',
- 'datetime',
- 'int',
- 'int'
+ $this->db->prepareArray(
+ "UPDATE arsse_feeds SET title = ?, source = ?, updated = CURRENT_TIMESTAMP, modified = ?, etag = ?, err_count = 0, err_msg = '', next_fetch = ?, size = ?, icon = ? WHERE id = ?",
+ ["str", "str", "datetime", "strict str", "datetime", "int", "int", "int"]
)->run(
$feed->data->title,
- $feed->favicon,
$feed->data->siteUrl,
$feed->lastModified,
$feed->resource->getEtag(),
$feed->nextFetch,
sizeof($feed->data->items),
+ $icon,
$feedID
);
$tr->commit();
@@ -1184,6 +1336,30 @@ class Database {
return $out;
}
+ /** Retrieves the set of filters users have applied to a given feed
+ *
+ * The result is an associative array whose keys are usernames, values
+ * being an array in turn with the following keys:
+ *
+ * - "keep": The "keep" rule as a prepared pattern; any articles which fail to match this rule are hidden
+ * - "block": The block rule as a prepared pattern; any articles which match this rule are hidden
+ */
+ public function feedRulesGet(int $feedID): array {
+ $out = [];
+ $result = $this->db->prepare("SELECT owner, coalesce(keep_rule, '') as keep, coalesce(block_rule, '') as block from arsse_subscriptions where feed = ? and (coalesce(keep_rule, '') || coalesce(block_rule, '')) <> '' order by owner", "int")->run($feedID);
+ foreach ($result as $row) {
+ try {
+ $keep = Rule::prep($row['keep']);
+ $block = Rule::prep($row['block']);
+ } catch (RuleException $e) {
+ // invalid rules should not normally appear in the database, but it's possible
+ continue;
+ }
+ $out[$row['owner']] = ['keep' => $keep, 'block' => $block];
+ }
+ return $out;
+ }
+
/** Retrieves various identifiers for the latest $count articles in the given newsfeed. The identifiers are:
*
* - "id": The database record key for the article
@@ -1226,16 +1402,47 @@ class Database {
[$cHashUC, $tHashUC, $vHashUC] = $this->generateIn($hashesUC, "str");
[$cHashTC, $tHashTC, $vHashTC] = $this->generateIn($hashesTC, "str");
// perform the query
- return $articles = $this->db->prepare(
+ return $this->db->prepareArray(
"SELECT id, edited, guid, url_title_hash, url_content_hash, title_content_hash FROM arsse_articles WHERE feed = ? and (guid in($cId) or url_title_hash in($cHashUT) or url_content_hash in($cHashUC) or title_content_hash in($cHashTC))",
- 'int',
- $tId,
- $tHashUT,
- $tHashUC,
- $tHashTC
+ ['int', $tId, $tHashUT, $tHashUC, $tHashTC]
)->run($feedID, $vId, $vHashUT, $vHashUC, $vHashTC);
}
+ /** Lists icons for feeds to which a user is subscribed
+ *
+ * The returned information for each icon is:
+ *
+ * - "id": The umeric identifier of the icon
+ * - "url": The URL of the icon
+ * - "type": The Content-Type of the icon e.g. "image/png"
+ * - "data": The icon itself, as a binary sring
+ *
+ * @param string $user The user whose subscription icons are to be retrieved
+ */
+ public function iconList(string $user): Db\Result {
+ return $this->db->prepare("SELECT distinct i.id, i.url, i.type, i.data from arsse_icons as i join arsse_feeds as f on i.id = f.icon join arsse_subscriptions as s on s.feed = f.id where s.owner = ?", "str")->run($user);
+ }
+
+ /** Deletes orphaned icons from the database
+ *
+ * Icons are orphaned if no subscribed newsfeed uses them.
+ */
+ public function iconCleanup(): int {
+ $tr = $this->begin();
+ // first unmark any icons which are no longer orphaned; an icon is considered orphaned if it is not used or only used by feeds which are themselves orphaned
+ $this->db->query("UPDATE arsse_icons set orphaned = null where id in (select distinct icon from arsse_feeds where icon is not null and orphaned is null)");
+ // next mark any newly orphaned icons with the current date and time
+ $this->db->query("UPDATE arsse_icons set orphaned = CURRENT_TIMESTAMP where orphaned is null and id not in (select distinct icon from arsse_feeds where icon is not null and orphaned is null)");
+ // finally delete icons that have been orphaned longer than the feed retention period, if a a purge threshold has been specified
+ $out = 0;
+ if (Arsse::$conf->purgeFeeds) {
+ $limit = Date::sub(Arsse::$conf->purgeFeeds);
+ $out += $this->db->prepare("DELETE from arsse_icons where orphaned <= ?", "datetime")->run($limit)->changes();
+ }
+ $tr->commit();
+ return $out;
+ }
+
/** Returns an associative array of result column names and their SQL computations for article queries
*
* This is used for whitelisting and defining both output column and order-by columns, as well as for resolution of some context options
@@ -1243,27 +1450,32 @@ class Database {
protected function articleColumns(): array {
$greatest = $this->db->sqlToken("greatest");
return [
- 'id' => "arsse_articles.id",
- 'edition' => "latest_editions.edition",
- 'url' => "arsse_articles.url",
- 'title' => "arsse_articles.title",
- 'author' => "arsse_articles.author",
- 'content' => "arsse_articles.content",
- 'guid' => "arsse_articles.guid",
- 'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash",
- 'folder' => "coalesce(arsse_subscriptions.folder,0)",
- 'subscription' => "arsse_subscriptions.id",
- 'feed' => "arsse_subscriptions.feed",
- 'starred' => "coalesce(arsse_marks.starred,0)",
- 'unread' => "abs(coalesce(arsse_marks.read,0) - 1)",
- 'note' => "coalesce(arsse_marks.note,'')",
- 'published_date' => "arsse_articles.published",
- 'edited_date' => "arsse_articles.edited",
- 'modified_date' => "arsse_articles.modified",
- 'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(label_stats.modified, '0001-01-01 00:00:00'))",
- 'subscription_title' => "coalesce(arsse_subscriptions.title, arsse_feeds.title)",
- 'media_url' => "arsse_enclosures.url",
- 'media_type' => "arsse_enclosures.type",
+ 'id' => "arsse_articles.id", // The article's unchanging numeric ID
+ 'edition' => "latest_editions.edition", // The article's numeric ID which increases each time it is modified in the feed
+ 'latest_edition' => "max(latest_editions.edition)", // The most recent of all editions
+ 'url' => "arsse_articles.url", // The URL of the article's full content
+ 'title' => "arsse_articles.title", // The title
+ 'author' => "arsse_articles.author", // The name of the author
+ 'content' => "coalesce(case when arsse_subscriptions.scrape = 1 then arsse_articles.content_scraped end, arsse_articles.content)", // The article content
+ 'guid' => "arsse_articles.guid", // The GUID of the article, as presented in the feed (NOTE: Picofeed actually provides a hash of the ID)
+ 'fingerprint' => "arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash", // A combination of three hashes
+ 'folder' => "coalesce(arsse_subscriptions.folder,0)", // The folder of the article's feed. This is mainly for use in WHERE clauses
+ 'top_folder' => "coalesce(folder_data.top,0)", // The top-most folder of the article's feed. This is mainly for use in WHERE clauses
+ 'folder_name' => "folder_data.name", // The name of the folder of the article's feed. This is mainly for use in WHERE clauses
+ 'top_folder_name' => "folder_data.top_name", // The name of the top-most folder of the article's feed. This is mainly for use in WHERE clauses
+ 'subscription' => "arsse_subscriptions.id", // The article's parent subscription
+ 'feed' => "arsse_subscriptions.feed", // The article's parent feed
+ 'hidden' => "coalesce(arsse_marks.hidden,0)", // Whether the article is hidden
+ 'starred' => "coalesce(arsse_marks.starred,0)", // Whether the article is starred
+ 'unread' => "abs(coalesce(arsse_marks.read,0) - 1)", // Whether the article is unread
+ 'note' => "coalesce(arsse_marks.note,'')", // The article's note, if any
+ 'published_date' => "arsse_articles.published", // The date at which the article was first published i.e. its creation date
+ 'edited_date' => "arsse_articles.edited", // The date at which the article was last edited according to the feed
+ 'modified_date' => "arsse_articles.modified", // The date at which the article was last updated in our database
+ 'marked_date' => "$greatest(arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(label_stats.modified, '0001-01-01 00:00:00'))", // The date at which the article metadata was last modified by the user
+ 'subscription_title' => "coalesce(arsse_subscriptions.title, arsse_feeds.title)", // The parent subscription's title
+ 'media_url' => "arsse_enclosures.url", // The URL of the article's enclosure, if any (NOTE: Picofeed only exposes one enclosure)
+ 'media_type' => "arsse_enclosures.type", // The Content-Type of the article's enclosure, if any
];
}
@@ -1306,7 +1518,7 @@ class Database {
} else {
// normalize requested output and sorting columns
$norm = function($v) {
- return trim(strtolower(ValueInfo::normalize($v, ValueInfo::T_STRING)));
+ return trim(strtolower(V::normalize($v, V::T_STRING)));
};
$cols = array_map($norm, $cols);
// make an output column list
@@ -1319,6 +1531,7 @@ class Database {
}
$outColumns = implode(",", $outColumns);
}
+ assert(strlen($outColumns) > 0, new \Exception("No input columns matched whitelist"));
// define the basic query, to which we add lots of stuff where necessary
$q = new Query(
"SELECT
@@ -1326,6 +1539,7 @@ class Database {
from arsse_articles
join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed and arsse_subscriptions.owner = ?
join arsse_feeds on arsse_subscriptions.feed = arsse_feeds.id
+ left join folder_data on arsse_subscriptions.folder = folder_data.id
left join arsse_marks on arsse_marks.subscription = arsse_subscriptions.id and arsse_marks.article = arsse_articles.id
left join arsse_enclosures on arsse_enclosures.article = arsse_articles.id
join (
@@ -1337,6 +1551,8 @@ class Database {
["str", "str"],
[$user, $user]
);
+ $q->setCTE("topmost(f_id,top)", "SELECT id,id from arsse_folders where owner = ? and parent is null union all select id,top from arsse_folders join topmost on parent=f_id", ["str"], [$user]);
+ $q->setCTE("folder_data(id,name,top,top_name)", "SELECT f1.id, f1.name, top, f2.name from arsse_folders as f1 join topmost on f1.id = f_id join arsse_folders as f2 on f2.id = top");
$q->setLimit($context->limit, $context->offset);
// handle the simple context options
$options = [
@@ -1359,6 +1575,7 @@ class Database {
"subscriptions" => ["subscription", "in", "int", ""],
"unread" => ["unread", "=", "bool", ""],
"starred" => ["starred", "=", "bool", ""],
+ "hidden" => ["hidden", "=", "bool", ""],
];
foreach ($options as $m => [$col, $op, $type, $pair]) {
if (!$context->$m()) {
@@ -1518,10 +1735,10 @@ class Database {
}
// handle text-matching context options
$options = [
- "titleTerms" => ["arsse_articles.title"],
- "searchTerms" => ["arsse_articles.title", "arsse_articles.content"],
- "authorTerms" => ["arsse_articles.author"],
- "annotationTerms" => ["arsse_marks.note"],
+ "titleTerms" => ["title"],
+ "searchTerms" => ["title", "content"],
+ "authorTerms" => ["author"],
+ "annotationTerms" => ["note"],
];
foreach ($options as $m => $columns) {
if (!$context->$m()) {
@@ -1529,6 +1746,10 @@ class Database {
} elseif (!$context->$m) {
throw new Db\ExceptionInput("tooShort", ['field' => $m, 'action' => $this->caller(), 'min' => 1]); // must have at least one array element
}
+ $columns = array_map(function($c) use ($colDefs) {
+ assert(isset($colDefs[$c]), new Exception("constantUnknown", $c));
+ return $colDefs[$c];
+ }, $columns);
$q->setWhere(...$this->generateSearch($context->$m, $columns));
}
// further handle exclusionary text-matching context options
@@ -1536,6 +1757,10 @@ class Database {
if (!$context->not->$m() || !$context->not->$m) {
continue;
}
+ $columns = array_map(function($c) use ($colDefs) {
+ assert(isset($colDefs[$c]), new Exception("constantUnknown", $c));
+ return $colDefs[$c];
+ }, $columns);
$q->setWhereNot(...$this->generateSearch($context->not->$m, $columns, true));
}
// return the query
@@ -1552,9 +1777,6 @@ class Database {
* @param array $sort The columns to sort the result by eg. "edition desc" in decreasing order of importance
*/
public function articleList(string $user, Context $context = null, array $fields = ["id"], array $sort = []): Db\Result {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// make a base query based on context and output columns
$context = $context ?? new Context;
$q = $this->articleQuery($user, $context, $fields);
@@ -1571,9 +1793,9 @@ class Database {
$order = $col[1] ?? "";
$col = $col[0];
if ($order === "desc") {
- $order = " desc";
+ $order = " ".$this->db->sqlToken("desc");
} elseif ($order === "asc" || $order === "") {
- $order = "";
+ $order = " ".$this->db->sqlToken("asc");
} else {
// column direction spec is bogus
continue;
@@ -1599,9 +1821,6 @@ class Database {
* @param Context $context The search context
*/
public function articleCount(string $user, Context $context = null): int {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$context = $context ?? new Context;
$q = $this->articleQuery($user, $context, []);
return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
@@ -1613,35 +1832,35 @@ class Database {
*
* - "read": Whether the article should be marked as read (true) or unread (false)
* - "starred": Whether the article should (true) or should not (false) be marked as starred/favourite
+ * - "hidden": Whether the article should (true) or should not (false) be suppressed from normal listings; this is normally set by the system rather than the user directly
* - "note": A string containing a freeform plain-text note for the article
*
* @param string $user The user who owns the articles to be modified
* @param array $data An associative array of properties to modify. Anything not specified will remain unchanged
* @param Context $context The query context to match articles against
+ * @param bool $updateTimestamp Whether to also update the timestamp. This should only be false if a mark is changed as a result of an automated action not taken by the user
*/
- 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]);
- }
+ public function articleMark(string $user, array $data, Context $context = null, bool $updateTimestamp = true): int {
$data = [
'read' => $data['read'] ?? null,
'starred' => $data['starred'] ?? null,
+ 'hidden' => $data['hidden'] ?? null,
'note' => $data['note'] ?? null,
];
- if (!isset($data['read']) && !isset($data['starred']) && !isset($data['note'])) {
+ if (!isset($data['read']) && !isset($data['starred']) && !isset($data['hidden']) && !isset($data['note'])) {
return 0;
}
$context = $context ?? new Context;
$tr = $this->begin();
$out = 0;
- if ($data['read'] || $data['starred'] || strlen($data['note'] ?? "")) {
+ if ($data['read'] || $data['starred'] || $data['hidden'] || strlen($data['note'] ?? "")) {
// first prepare a query to insert any missing marks rows for the articles we want to mark
// but only insert new mark records if we're setting at least one "positive" mark
$q = $this->articleQuery($user, $context, ["id", "subscription", "note"]);
- $q->setWhere("arsse_marks.starred is null"); // null means there is no marks row for the article
+ $q->setWhere("arsse_marks.starred is null"); // null means there is no marks row for the article, because the column is defined not-null
$this->db->prepare("INSERT INTO arsse_marks(article,subscription,note) ".$q->getQuery(), $q->getTypes())->run($q->getValues());
}
- if (isset($data['read']) && (isset($data['starred']) || isset($data['note'])) && ($context->edition() || $context->editions())) {
+ if (isset($data['read']) && (isset($data['starred']) || isset($data['hidden']) || isset($data['note'])) && ($context->edition() || $context->editions())) {
// if marking by edition both read and something else, do separate marks for starred and note than for read
// marking as read is ignored if the edition is not the latest, but the same is not true of the other two marks
$this->db->query("UPDATE arsse_marks set touched = 0 where touched <> 0");
@@ -1657,20 +1876,24 @@ class Database {
} else {
$context->articles($this->editionArticle(...$context->editions))->editions(null);
}
- // set starred and/or note marks (unless all requested editions actually do not exist)
+ // set starred, hidden, and/or note marks (unless all requested editions actually do not exist)
if ($context->article || $context->articles) {
$q = $this->articleQuery($user, $context, ["id", "subscription"]);
- $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred))", ["str", "bool"], [$data['note'], $data['starred']]);
+ $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool"], [$data['note'], $data['starred'], $data['hidden']]);
$q->pushCTE("target_articles(article,subscription)");
$data = array_filter($data, function($v) {
return isset($v);
});
- [$set, $setTypes, $setValues] = $this->generateSet($data, ['starred' => "bool", 'note' => "str"]);
+ [$set, $setTypes, $setValues] = $this->generateSet($data, ['starred' => "bool", 'hidden' => "bool", 'note' => "str"]);
$q->setBody("UPDATE arsse_marks set touched = 1, $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues);
$this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues());
}
// finally set the modification date for all touched marks and return the number of affected marks
- $out = $this->db->query("UPDATE arsse_marks set modified = CURRENT_TIMESTAMP, touched = 0 where touched = 1")->changes();
+ if ($updateTimestamp) {
+ $out = $this->db->query("UPDATE arsse_marks set modified = CURRENT_TIMESTAMP, touched = 0 where touched = 1")->changes();
+ } else {
+ $out = $this->db->query("UPDATE arsse_marks set touched = 0 where touched = 1")->changes();
+ }
} else {
if (!isset($data['read']) && ($context->edition() || $context->editions())) {
// get the articles associated with the requested editions
@@ -1684,20 +1907,23 @@ class Database {
}
}
$q = $this->articleQuery($user, $context, ["id", "subscription"]);
- $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read))", ["str", "bool", "bool"], [$data['note'], $data['starred'], $data['read']]);
+ $q->setWhere("(arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read) or arsse_marks.hidden <> coalesce(?,arsse_marks.hidden))", ["str", "bool", "bool", "bool"], [$data['note'], $data['starred'], $data['read'], $data['hidden']]);
$q->pushCTE("target_articles(article,subscription)");
$data = array_filter($data, function($v) {
return isset($v);
});
- [$set, $setTypes, $setValues] = $this->generateSet($data, ['read' => "bool", 'starred' => "bool", 'note' => "str"]);
- $q->setBody("UPDATE arsse_marks set $set, modified = CURRENT_TIMESTAMP where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues);
+ [$set, $setTypes, $setValues] = $this->generateSet($data, ['read' => "bool", 'starred' => "bool", 'hidden' => "bool", 'note' => "str"]);
+ if ($updateTimestamp) {
+ $set .= ", modified = CURRENT_TIMESTAMP";
+ }
+ $q->setBody("UPDATE arsse_marks set $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles)", $setTypes, $setValues);
$out = $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->changes();
}
$tr->commit();
return $out;
}
- /** Returns statistics about the articles starred by the given user
+ /** Returns statistics about the articles starred by the given user. Hidden articles are excluded
*
* The associative array returned has the following keys:
*
@@ -1706,16 +1932,13 @@ class Database {
* - "read": The count of starred articles which are read
*/
public function articleStarred(string $user): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
return $this->db->prepare(
"SELECT
count(*) as total,
coalesce(sum(abs(\"read\" - 1)),0) as unread,
coalesce(sum(\"read\"),0) as \"read\"
FROM (
- select \"read\" from arsse_marks where starred = 1 and subscription in (select id from arsse_subscriptions where owner = ?)
+ select \"read\" from arsse_marks where starred = 1 and hidden <> 1 and subscription in (select id from arsse_subscriptions where owner = ?)
) as starred_data",
"str"
)->run($user)->getRow();
@@ -1728,9 +1951,6 @@ class Database {
* @param boolean $byName Whether to return the label names (true) instead of the numeric label identifiers (false)
*/
public function articleLabelsGet(string $user, $id, bool $byName = false): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$id = $this->articleValidateId($user, $id)['article'];
$field = !$byName ? "id" : "name";
$out = $this->db->prepare("SELECT $field from arsse_labels join arsse_label_members on arsse_label_members.label = arsse_labels.id where owner = ? and article = ? and assigned = 1 order by $field", "str", "int")->run($user, $id)->getAll();
@@ -1739,9 +1959,6 @@ class Database {
/** Returns the author-supplied categories associated with an article */
public function articleCategoriesGet(string $user, $id): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$id = $this->articleValidateId($user, $id)['article'];
$out = $this->db->prepare("SELECT name from arsse_categories where article = ? order by name", "int")->run($id)->getAll();
if (!$out) {
@@ -1754,21 +1971,50 @@ class Database {
/** Deletes from the database articles which are beyond the configured clean-up threshold */
public function articleCleanup(): bool {
- $query = $this->db->prepare(
+ $integer = $this->db->sqlToken("integer");
+ $query = $this->db->prepareArray(
"WITH RECURSIVE
- exempt_articles as (SELECT id from arsse_articles join (SELECT article, max(id) as edition from arsse_editions group by article) as latest_editions on arsse_articles.id = latest_editions.article where feed = ? order by edition desc limit ?),
- target_articles as (
- select id from arsse_articles
- left join (select article, sum(starred) as starred, sum(\"read\") as \"read\", max(arsse_marks.modified) as marked_date from arsse_marks join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription group by article) as mark_stats on mark_stats.article = arsse_articles.id
- left join (select feed, count(*) as subs from arsse_subscriptions group by feed) as feed_stats on feed_stats.feed = arsse_articles.feed
- where arsse_articles.feed = ? and coalesce(starred,0) = 0 and (coalesce(marked_date,modified) <= ? or (coalesce(\"read\",0) = coalesce(subs,0) and coalesce(marked_date,modified) <= ?))
- )
+ exempt_articles as (
+ SELECT
+ id
+ from arsse_articles join (
+ SELECT article, max(id) as edition from arsse_editions group by article
+ ) as latest_editions on arsse_articles.id = latest_editions.article
+ where feed = ? order by edition desc limit ?
+ ),
+ target_articles as (
+ SELECT
+ id
+ from arsse_articles
+ join (
+ select
+ feed,
+ count(*) as subs
+ from arsse_subscriptions
+ where feed = ?
+ group by feed
+ ) as feed_stats on feed_stats.feed = arsse_articles.feed
+ left join (
+ select
+ article,
+ sum(cast((starred = 1 and hidden = 0) as $integer)) as starred,
+ sum(cast((\"read\" = 1 or hidden = 1) as $integer)) as \"read\",
+ max(arsse_marks.modified) as marked_date
+ from arsse_marks
+ group by article
+ ) as mark_stats on mark_stats.article = arsse_articles.id
+ where
+ coalesce(starred,0) = 0
+ and (
+ coalesce(marked_date,modified) <= ?
+ or (
+ coalesce(\"read\",0) = coalesce(subs,0)
+ and coalesce(marked_date,modified) <= ?
+ )
+ )
+ )
DELETE FROM arsse_articles WHERE id not in (select id from exempt_articles) and id in (select id from target_articles)",
- "int",
- "int",
- "int",
- "datetime",
- "datetime"
+ ["int", "int", "int", "datetime", "datetime"]
);
$limitRead = null;
$limitUnread = null;
@@ -1794,18 +2040,17 @@ class Database {
* @param integer $id The identifier of the article to validate
*/
protected function articleValidateId(string $user, $id): array {
- if (!ValueInfo::id($id)) {
+ if (!V::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "article", 'type' => "int > 0"]); // @codeCoverageIgnore
}
- $out = $this->db->prepare(
+ $out = $this->db->prepareArray(
"SELECT articles.article as article, max(arsse_editions.id) as edition from (
select arsse_articles.id as article
FROM arsse_articles
join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed
WHERE arsse_articles.id = ? and arsse_subscriptions.owner = ?
- ) as articles join arsse_editions on arsse_editions.article = articles.article group by articles.article",
- "int",
- "str"
+ ) as articles left join arsse_editions on arsse_editions.article = articles.article group by articles.article",
+ ["int", "str"]
)->run($id, $user)->getRow();
if (!$out) {
throw new Db\ExceptionInput("subjectMissing", ["action" => $this->caller(), "field" => "article", 'id' => $id]);
@@ -1821,10 +2066,10 @@ class Database {
* @param integer $id The identifier of the edition to validate
*/
protected function articleValidateEdition(string $user, int $id): array {
- if (!ValueInfo::id($id)) {
+ if (!V::id($id)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "edition", 'type' => "int > 0"]); // @codeCoverageIgnore
}
- $out = $this->db->prepare(
+ $out = $this->db->prepareArray(
"SELECT
arsse_editions.id, arsse_editions.article, edition_stats.edition as current
from arsse_editions
@@ -1832,8 +2077,7 @@ class Database {
join arsse_subscriptions on arsse_subscriptions.feed = arsse_articles.feed
join (select article, max(id) as edition from arsse_editions group by article) as edition_stats on edition_stats.article = arsse_editions.article
where arsse_editions.id = ? and arsse_subscriptions.owner = ?",
- "int",
- "str"
+ ["int", "str"]
)->run($id, $user)->getRow();
if (!$out) {
throw new Db\ExceptionInput("subjectMissing", ["action" => $this->caller(), "field" => "edition", 'id' => $id]);
@@ -1843,18 +2087,9 @@ class Database {
/** Returns the numeric identifier of the most recent edition of an article matching the given context */
public function editionLatest(string $user, Context $context = null): int {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$context = $context ?? new Context;
- $q = new Query("SELECT max(arsse_editions.id) from arsse_editions left join arsse_articles on article = arsse_articles.id join arsse_subscriptions on arsse_articles.feed = arsse_subscriptions.feed and arsse_subscriptions.owner = ?", "str", $user);
- if ($context->subscription()) {
- // if a subscription is specified, make sure it exists
- $this->subscriptionValidateId($user, $context->subscription);
- // a simple WHERE clause is required here
- $q->setWhere("arsse_subscriptions.id = ?", "int", $context->subscription);
- }
- return (int) $this->db->prepare($q->getQuery(), $q->getTypes())->run($q->getValues())->getValue();
+ $q = $this->articleQuery($user, $context, ["latest_edition"]);
+ return (int) $this->db->prepare((string) $q, $q->getTypes())->run($q->getValues())->getValue();
}
/** Returns a map between all the given edition identifiers and their associated article identifiers */
@@ -1874,10 +2109,6 @@ class Database {
* @param array $data An associative array defining the label's properties; currently only "name" is understood
*/
public function labelAdd(string $user, array $data): int {
- // if the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- 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);
@@ -1898,21 +2129,23 @@ class Database {
* @param boolean $includeEmpty Whether to include (true) or supress (false) labels which have no articles assigned to them
*/
public function labelList(string $user, bool $includeEmpty = true): Db\Result {
- // if the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
- return $this->db->prepare(
+ $integer = $this->db->sqlToken("integer");
+ return $this->db->prepareArray(
"SELECT * FROM (
SELECT
- id,name,coalesce(articles,0) as articles,coalesce(marked,0) as \"read\"
+ id,
+ name,
+ coalesce(articles - coalesce(hidden, 0), 0) as articles,
+ coalesce(marked, 0) as \"read\"
from arsse_labels
left join (
SELECT label, sum(assigned) as articles from arsse_label_members group by label
) as label_stats on label_stats.label = arsse_labels.id
left join (
- SELECT
- label, sum(\"read\") as marked
+ SELECT
+ label,
+ sum(hidden) as hidden,
+ sum(cast((\"read\" = 1 and hidden = 0) as $integer)) as marked
from arsse_marks
join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription
join arsse_label_members on arsse_label_members.article = arsse_marks.article
@@ -1921,11 +2154,8 @@ class Database {
) as mark_stats on mark_stats.label = arsse_labels.id
WHERE owner = ?
) as label_data
- where articles >= ? order by name
- ",
- "str",
- "str",
- "int"
+ where articles >= ? order by name",
+ ["str", "str", "int"]
)->run($user, $user, !$includeEmpty);
}
@@ -1938,9 +2168,6 @@ class Database {
* @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false)
*/
public function labelRemove(string $user, $id, bool $byName = false): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$this->labelValidateId($user, $id, $byName, false);
$field = $byName ? "name" : "id";
$type = $byName ? "str" : "int";
@@ -1965,33 +2192,33 @@ class Database {
* @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false)
*/
public function labelPropertiesGet(string $user, $id, bool $byName = false): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$this->labelValidateId($user, $id, $byName, false);
$field = $byName ? "name" : "id";
$type = $byName ? "str" : "int";
- $out = $this->db->prepare(
+ $integer = $this->db->sqlToken("integer");
+ $out = $this->db->prepareArray(
"SELECT
- id,name,coalesce(articles,0) as articles,coalesce(marked,0) as \"read\"
+ id,
+ name,
+ coalesce(articles - coalesce(hidden, 0), 0) as articles,
+ coalesce(marked, 0) as \"read\"
FROM arsse_labels
left join (
SELECT label, sum(assigned) as articles from arsse_label_members group by label
) as label_stats on label_stats.label = arsse_labels.id
left join (
- SELECT
- label, sum(\"read\") as marked
+ SELECT
+ label,
+ sum(hidden) as hidden,
+ sum(cast((\"read\" = 1 and hidden = 0) as $integer)) as marked
from arsse_marks
join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription
join arsse_label_members on arsse_label_members.article = arsse_marks.article
where arsse_subscriptions.owner = ?
group by label
) as mark_stats on mark_stats.label = arsse_labels.id
- WHERE $field = ? and owner = ?
- ",
- "str",
- $type,
- "str"
+ WHERE $field = ? and owner = ?",
+ ["str", $type, "str"]
)->run($user, $id, $user)->getRow();
if (!$out) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "label", 'id' => $id]);
@@ -2007,9 +2234,6 @@ class Database {
* @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false)
*/
public function labelPropertiesSet(string $user, $id, array $data, bool $byName = false): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$this->labelValidateId($user, $id, $byName, false);
if (isset($data['name'])) {
$this->labelValidateName($data['name']);
@@ -2038,22 +2262,26 @@ class Database {
* @param boolean $byName Whether to interpret the $id parameter as the label's name (true) or identifier (false)
*/
public function labelArticlesGet(string $user, $id, bool $byName = false): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
+ $c = (new Context)->hidden(false);
+ if ($byName) {
+ $c->labelName($id);
+ } else {
+ $c->label($id);
+ }
+ try {
+ $q = $this->articleQuery($user, $c);
+ $q->setOrder("id");
+ $out = $this->db->prepare((string) $q, $q->getTypes())->run($q->getValues())->getAll();
+ } catch (Db\ExceptionInput $e) {
+ if ($e->getCode() === 10235) {
+ throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "label", 'id' => $id]);
+ }
+ throw $e;
}
- // 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 = id where assigned = 1 and $field = ? and owner = ? order by article", $type, "str")->run($id, $user)->getAll();
if (!$out) {
- // if no results were returned, do a full validation on the label ID
- $this->labelValidateId($user, $id, $byName, true, true);
- // 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");
+ return array_column($out, "id");
}
}
@@ -2067,9 +2295,6 @@ class Database {
*/
public function labelArticlesSet(string $user, $id, Context $context, int $mode = self::ASSOC_ADD, bool $byName = false): int {
assert(in_array($mode, [self::ASSOC_ADD, self::ASSOC_REMOVE, self::ASSOC_REPLACE]), new Exception("constantUnknown", $mode));
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// validate the tag ID, and get the numeric ID if matching by name
$id = $this->labelValidateId($user, $id, $byName, true)['id'];
// get the list of articles matching the context
@@ -2131,10 +2356,10 @@ class Database {
* @param boolean $subject Whether the label is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails
*/
protected function labelValidateId(string $user, $id, bool $byName, bool $checkDb = true, bool $subject = false): array {
- if (!$byName && !ValueInfo::id($id)) {
+ if (!$byName && !V::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)) {
+ } elseif ($byName && !(V::str($id) & V::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) {
@@ -2155,12 +2380,12 @@ class Database {
/** Ensures a prospective label name is syntactically valid and raises an exception otherwise */
protected function labelValidateName($name): bool {
- $info = ValueInfo::str($name);
- if ($info & (ValueInfo::NULL | ValueInfo::EMPTY)) {
+ $info = V::str($name);
+ if ($info & (V::NULL | V::EMPTY)) {
throw new Db\ExceptionInput("missing", ["action" => $this->caller(), "field" => "name"]);
- } elseif ($info & ValueInfo::WHITE) {
+ } elseif ($info & V::WHITE) {
throw new Db\ExceptionInput("whitespace", ["action" => $this->caller(), "field" => "name"]);
- } elseif (!($info & ValueInfo::VALID)) {
+ } elseif (!($info & V::VALID)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "name", 'type' => "string"]);
} else {
return true;
@@ -2175,10 +2400,6 @@ class Database {
* @param array $data An associative array defining the tag's properties; currently only "name" is understood
*/
public function tagAdd(string $user, array $data): int {
- // if the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// validate the tag name
$name = array_key_exists("name", $data) ? $data['name'] : "";
$this->tagValidateName($name, true);
@@ -2198,11 +2419,7 @@ class Database {
* @param boolean $includeEmpty Whether to include (true) or supress (false) tags which have no subscriptions assigned to them
*/
public function tagList(string $user, bool $includeEmpty = true): Db\Result {
- // if the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
- return $this->db->prepare(
+ return $this->db->prepareArray(
"SELECT * FROM (
SELECT
id,name,coalesce(subscriptions,0) as subscriptions
@@ -2210,10 +2427,8 @@ class Database {
left join (SELECT tag, sum(assigned) as subscriptions from arsse_tag_members group by tag) as tag_stats on tag_stats.tag = arsse_tags.id
WHERE owner = ?
) as tag_data
- where subscriptions >= ? order by name
- ",
- "str",
- "int"
+ where subscriptions >= ? order by name",
+ ["str", "int"]
)->run($user, !$includeEmpty);
}
@@ -2229,11 +2444,7 @@ class Database {
* @param string $user The user whose tags are to be listed
*/
public function tagSummarize(string $user): Db\Result {
- // if the user isn't authorized to perform this action then throw an exception.
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
- return $this->db->prepare(
+ return $this->db->prepareArray(
"SELECT
arsse_tags.id as id,
arsse_tags.name as name,
@@ -2241,7 +2452,7 @@ class Database {
FROM arsse_tag_members
join arsse_tags on arsse_tags.id = arsse_tag_members.tag
WHERE arsse_tags.owner = ? and assigned = 1",
- "str"
+ ["str"]
)->run($user);
}
@@ -2254,9 +2465,6 @@ class Database {
* @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false)
*/
public function tagRemove(string $user, $id, bool $byName = false): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$this->tagValidateId($user, $id, $byName, false);
$field = $byName ? "name" : "id";
$type = $byName ? "str" : "int";
@@ -2280,21 +2488,16 @@ class Database {
* @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false)
*/
public function tagPropertiesGet(string $user, $id, bool $byName = false): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$this->tagValidateId($user, $id, $byName, false);
$field = $byName ? "name" : "id";
$type = $byName ? "str" : "int";
- $out = $this->db->prepare(
+ $out = $this->db->prepareArray(
"SELECT
id,name,coalesce(subscriptions,0) as subscriptions
FROM arsse_tags
left join (SELECT tag, sum(assigned) as subscriptions from arsse_tag_members group by tag) as tag_stats on tag_stats.tag = arsse_tags.id
- WHERE $field = ? and owner = ?
- ",
- $type,
- "str"
+ WHERE $field = ? and owner = ?",
+ [$type, "str"]
)->run($id, $user)->getRow();
if (!$out) {
throw new Db\ExceptionInput("subjectMissing", ["action" => __FUNCTION__, "field" => "tag", 'id' => $id]);
@@ -2310,9 +2513,6 @@ class Database {
* @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false)
*/
public function tagPropertiesSet(string $user, $id, array $data, bool $byName = false): bool {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
$this->tagValidateId($user, $id, $byName, false);
if (isset($data['name'])) {
$this->tagValidateName($data['name']);
@@ -2341,9 +2541,6 @@ class Database {
* @param boolean $byName Whether to interpret the $id parameter as the tag's name (true) or identifier (false)
*/
public function tagSubscriptionsGet(string $user, $id, bool $byName = false): array {
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// just do a syntactic check on the tag ID
$this->tagValidateId($user, $id, $byName, false);
$field = !$byName ? "id" : "name";
@@ -2370,9 +2567,6 @@ class Database {
*/
public function tagSubscriptionsSet(string $user, $id, array $subscriptions, int $mode = self::ASSOC_ADD, bool $byName = false): int {
assert(in_array($mode, [self::ASSOC_ADD, self::ASSOC_REMOVE, self::ASSOC_REPLACE]), new Exception("constantUnknown", $mode));
- if (!Arsse::$user->authorize($user, __FUNCTION__)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => __FUNCTION__, "user" => $user]);
- }
// validate the tag ID, and get the numeric ID if matching by name
$id = $this->tagValidateId($user, $id, $byName, true)['id'];
// an empty subscription list is a special case
@@ -2430,10 +2624,10 @@ class Database {
* @param boolean $subject Whether the tag is the subject (true) rather than the object (false) of the operation being performed; this only affects the semantics of the error message if validation fails
*/
protected function tagValidateId(string $user, $id, bool $byName, bool $checkDb = true, bool $subject = false): array {
- if (!$byName && !ValueInfo::id($id)) {
+ if (!$byName && !V::id($id)) {
// if we're not referring to a tag by name and the ID is invalid, throw an exception
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "tag", 'type' => "int > 0"]);
- } elseif ($byName && !(ValueInfo::str($id) & ValueInfo::VALID)) {
+ } elseif ($byName && !(V::str($id) & V::VALID)) {
// otherwise if we are referring to a tag by name but the ID is not a string, also throw an exception
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "tag", 'type' => "string"]);
} elseif ($checkDb) {
@@ -2454,12 +2648,12 @@ class Database {
/** Ensures a prospective tag name is syntactically valid and raises an exception otherwise */
protected function tagValidateName($name): bool {
- $info = ValueInfo::str($name);
- if ($info & (ValueInfo::NULL | ValueInfo::EMPTY)) {
+ $info = V::str($name);
+ if ($info & (V::NULL | V::EMPTY)) {
throw new Db\ExceptionInput("missing", ["action" => $this->caller(), "field" => "name"]);
- } elseif ($info & ValueInfo::WHITE) {
+ } elseif ($info & V::WHITE) {
throw new Db\ExceptionInput("whitespace", ["action" => $this->caller(), "field" => "name"]);
- } elseif (!($info & ValueInfo::VALID)) {
+ } elseif (!($info & V::VALID)) {
throw new Db\ExceptionInput("typeViolation", ["action" => $this->caller(), "field" => "name", 'type' => "string"]);
} else {
return true;
diff --git a/lib/Db/Driver.php b/lib/Db/Driver.php
index 1488b1b1..09f16e78 100644
--- a/lib/Db/Driver.php
+++ b/lib/Db/Driver.php
@@ -74,6 +74,9 @@ interface Driver {
* - "greatest": the GREATEST function implemented by PostgreSQL and MySQL
* - "nocase": the name of a general-purpose case-insensitive collation sequence
* - "like": the case-insensitive LIKE operator
+ * - "integer": the integer type to use for explicit casts
+ * - "asc": ascending sort order when dealing with nulls
+ * - "desc": descending sort order when dealing with nulls
*/
public function sqlToken(string $token): string;
diff --git a/lib/Db/MySQL/Driver.php b/lib/Db/MySQL/Driver.php
index 023a2819..9acd1eaa 100644
--- a/lib/Db/MySQL/Driver.php
+++ b/lib/Db/MySQL/Driver.php
@@ -81,6 +81,10 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
switch (strtolower($token)) {
case "nocase":
return '"utf8mb4_unicode_ci"';
+ case "integer":
+ return "signed integer";
+ case "asc":
+ return "";
default:
return $token;
}
@@ -160,6 +164,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
protected function makeConnection(string $db, string $user, string $password, string $host, int $port, string $socket): void {
$this->db = mysqli_init();
+ $this->db->options(\MYSQLI_SET_CHARSET_NAME, "utf8mb4");
+ $this->db->options(\MYSQLI_OPT_INT_AND_FLOAT_NATIVE, false);
$this->db->options(\MYSQLI_OPT_CONNECT_TIMEOUT, ceil(Arsse::$conf->dbTimeoutConnect));
@$this->db->real_connect($host, $user, $password, $db, $port, $socket);
if ($this->db->connect_errno) {
@@ -218,7 +224,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
// with MySQL each table must be analyzed separately, so we first have to get a list of tables
foreach ($this->query("SHOW TABLES like 'arsse\\_%'") as $table) {
$table = array_pop($table);
- if (!preg_match("/^arsse_[a-z_]+$/", $table)) {
+ if (!preg_match("/^arsse_[a-z_]+$/D", $table)) {
// table is not one of ours
continue; // @codeCoverageIgnore
}
diff --git a/lib/Db/PDOError.php b/lib/Db/PDOError.php
index bd15dc25..03035516 100644
--- a/lib/Db/PDOError.php
+++ b/lib/Db/PDOError.php
@@ -16,9 +16,9 @@ trait PDOError {
$err = $this->db->errorInfo();
}
if ($err[0] === "HY000") {
- return static::buildEngineException($err[1], $err[2]);
+ return static::buildEngineException((string) $err[1], (string) $err[2]);
} else {
- return static::buildStandardException($err[0], $err[2]);
+ return static::buildStandardException((string) $err[0], (string) $err[2]);
}
}
}
diff --git a/lib/Db/PostgreSQL/Driver.php b/lib/Db/PostgreSQL/Driver.php
index fccc0710..c22f0963 100644
--- a/lib/Db/PostgreSQL/Driver.php
+++ b/lib/Db/PostgreSQL/Driver.php
@@ -119,6 +119,10 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
return '"und-x-icu"';
case "like":
return "ilike";
+ case "asc":
+ return "nulls first";
+ case "desc":
+ return "desc nulls last";
default:
return $token;
}
diff --git a/lib/Db/PostgreSQL/PDOResult.php b/lib/Db/PostgreSQL/PDOResult.php
new file mode 100644
index 00000000..4920776f
--- /dev/null
+++ b/lib/Db/PostgreSQL/PDOResult.php
@@ -0,0 +1,26 @@
+cur = $this->set->fetch(\PDO::FETCH_ASSOC);
+ if ($this->cur !== false) {
+ foreach ($this->cur as $k => $v) {
+ if (is_resource($v)) {
+ $this->cur[$k] = stream_get_contents($v);
+ fclose($v);
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/lib/Db/PostgreSQL/PDOStatement.php b/lib/Db/PostgreSQL/PDOStatement.php
index c9b7b826..9929579a 100644
--- a/lib/Db/PostgreSQL/PDOStatement.php
+++ b/lib/Db/PostgreSQL/PDOStatement.php
@@ -6,6 +6,8 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\Db\PostgreSQL;
+use JKingWeb\Arsse\Db\Result;
+
class PDOStatement extends \JKingWeb\Arsse\Db\PDOStatement {
public static function mungeQuery(string $query, array $types, ...$extraData): string {
return Statement::mungeQuery($query, $types, false);
@@ -16,4 +18,16 @@ class PDOStatement extends \JKingWeb\Arsse\Db\PDOStatement {
// PostgreSQL uses SQLSTATE exclusively, so this is not used
return [];
}
+
+ public function runArray(array $values = []): Result {
+ $this->st->closeCursor();
+ $this->bindValues($values);
+ try {
+ $this->st->execute();
+ } catch (\PDOException $e) {
+ [$excClass, $excMsg, $excData] = $this->buildPDOException(true);
+ throw new $excClass($excMsg, $excData);
+ }
+ return new PDOResult($this->db, $this->st);
+ }
}
diff --git a/lib/Db/PostgreSQL/Result.php b/lib/Db/PostgreSQL/Result.php
index 03dba17f..7200ac35 100644
--- a/lib/Db/PostgreSQL/Result.php
+++ b/lib/Db/PostgreSQL/Result.php
@@ -10,6 +10,7 @@ class Result extends \JKingWeb\Arsse\Db\AbstractResult {
protected $db;
protected $r;
protected $cur;
+ protected $blobs = [];
// actual public methods
@@ -30,6 +31,11 @@ class Result extends \JKingWeb\Arsse\Db\AbstractResult {
public function __construct($db, $result) {
$this->db = $db;
$this->r = $result;
+ for ($a = 0, $stop = pg_num_fields($result); $a < $stop; $a++) {
+ if (pg_field_type($result, $a) === "bytea") {
+ $this->blobs[$a] = pg_field_name($result, $a);
+ }
+ }
}
public function __destruct() {
@@ -41,6 +47,14 @@ class Result extends \JKingWeb\Arsse\Db\AbstractResult {
public function valid() {
$this->cur = pg_fetch_row($this->r, null, \PGSQL_ASSOC);
- return $this->cur !== false;
+ if ($this->cur !== false) {
+ foreach ($this->blobs as $f) {
+ if ($this->cur[$f]) {
+ $this->cur[$f] = hex2bin(substr($this->cur[$f], 2));
+ }
+ }
+ return true;
+ }
+ return false;
}
}
diff --git a/lib/Db/PostgreSQL/Statement.php b/lib/Db/PostgreSQL/Statement.php
index 8c89053d..4472e8e5 100644
--- a/lib/Db/PostgreSQL/Statement.php
+++ b/lib/Db/PostgreSQL/Statement.php
@@ -44,6 +44,9 @@ class Statement extends \JKingWeb\Arsse\Db\AbstractStatement {
}
protected function bindValue($value, int $type, int $position): bool {
+ if ($value !== null && ($this->types[$position - 1] % self::T_NOT_NULL) === self::T_BINARY) {
+ $value = "\\x".bin2hex($value);
+ }
$this->in[] = $value;
return true;
}
diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php
index bef5ec65..7c5a1109 100644
--- a/lib/Db/SQLite3/Driver.php
+++ b/lib/Db/SQLite3/Driver.php
@@ -31,6 +31,12 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
$dbKey = Arsse::$conf->dbSQLite3Key;
$timeout = Arsse::$conf->dbSQLite3Timeout * 1000;
try {
+ // check whether the file exists; if it doesn't create the file and set its mode to rw-r-----
+ if ($dbFile !== ":memory:" && !file_exists($dbFile)) {
+ if (@touch($dbFile)) {
+ chmod($dbFile, 0640);
+ }
+ }
$this->makeConnection($dbFile, $dbKey);
} catch (\Throwable $e) {
// if opening the database doesn't work, check various pre-conditions to find out what the problem might be
@@ -114,6 +120,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver {
switch (strtolower($token)) {
case "greatest":
return "max";
+ case "asc":
+ return "";
default:
return $token;
}
diff --git a/lib/Factory.php b/lib/Factory.php
new file mode 100644
index 00000000..0dfcea85
--- /dev/null
+++ b/lib/Factory.php
@@ -0,0 +1,13 @@
+ $url], new \PicoFeed\Reader\SubscriptionNotFoundException('Unable to find a subscription'));
} else {
$out = $links[0];
}
@@ -45,6 +49,17 @@ class Feed {
return $out;
}
+ public static function discoverAll(string $url, string $username = '', string $password = ''): array {
+ // fetch the candidate feed
+ $f = self::download($url, "", "", $username, $password);
+ if ($f->reader->detectFormat($f->getContent())) {
+ // if the prospective URL is a feed, use it
+ return [$url];
+ } else {
+ return $f->reader->find($f->getUrl(), $f->getContent());
+ }
+ }
+
public function __construct(int $feedID = null, string $url, string $lastModified = '', string $etag = '', string $username = '', string $password = '', bool $scrape = false) {
// fetch the feed
$this->resource = self::download($url, $lastModified, $etag, $username, $password);
@@ -66,10 +81,14 @@ class Feed {
// we only really care if articles have been modified; if there are no new articles, act as if the feed is unchanged
if (!sizeof($this->newItems) && !sizeof($this->changedItems)) {
$this->modified = false;
- }
- // if requested, scrape full content for any new and changed items
- if ($scrape) {
- $this->scrape();
+ } else {
+ if ($feedID) {
+ $this->computeFilterRules($feedID);
+ }
+ // if requested, scrape full content for any new and changed items
+ if ($scrape) {
+ $this->scrape();
+ }
}
}
// compute the time at which the feed should next be fetched
@@ -100,27 +119,35 @@ class Feed {
$client->reader = $reader;
return $client;
} catch (PicoFeedException $e) {
- throw new Feed\Exception($url, $e); // @codeCoverageIgnore
+ throw new Feed\Exception("", ['url' => $url], $e); // @codeCoverageIgnore
} catch (\GuzzleHttp\Exception\GuzzleException $e) {
- throw new Feed\Exception($url, $e);
+ throw new Feed\Exception("", ['url' => $url], $e);
}
}
- protected function parse(): bool {
+ protected function parse(): void {
try {
$feed = $this->resource->reader->getParser(
$this->resource->getUrl(),
$this->resource->getContent(),
$this->resource->getEncoding()
)->execute();
- // Grab the favicon for the feed; returns an empty string if it cannot find one.
- // Some feeds might use a different domain (eg: feedburner), so the site url is
- // used instead of the feed's url.
- $this->favicon = (new Favicon)->find($feed->siteUrl);
} catch (PicoFeedException $e) {
- throw new Feed\Exception($this->resource->getUrl(), $e);
+ throw new Feed\Exception("", ['url' => $this->resource->getUrl()], $e);
} catch (\GuzzleHttp\Exception\GuzzleException $e) { // @codeCoverageIgnore
- throw new Feed\Exception($this->resource->getUrl(), $e); // @codeCoverageIgnore
+ throw new Feed\Exception("", ['url' => $this->resource->getUrl()], $e); // @codeCoverageIgnore
+ }
+
+ // Grab the favicon for the feed, or null if no valid icon is found
+ // Some feeds might use a different domain (eg: feedburner), so the site url is
+ // used instead of the feed's url.
+ $icon = new Favicon;
+ $this->iconUrl = $icon->find($feed->siteUrl, $feed->getIcon());
+ $this->iconData = $icon->getContent();
+ if (strlen($this->iconData)) {
+ $this->iconType = $icon->getType();
+ } else {
+ $this->iconUrl = $this->iconData = null;
}
// PicoFeed does not provide valid ids when there is no id element. Its solution
@@ -201,7 +228,6 @@ class Feed {
sort($f->categories);
}
$this->data = $feed;
- return true;
}
protected function deduplicateItems(array $items): array {
@@ -248,19 +274,19 @@ class Feed {
return $out;
}
- protected function matchToDatabase(int $feedID = null): bool {
+ protected function matchToDatabase(int $feedID = null): void {
// first perform deduplication on items
$items = $this->deduplicateItems($this->data->items);
// if we haven't been given a database feed ID to check against, all items are new
if (is_null($feedID)) {
$this->newItems = $items;
- return true;
+ return;
}
// get as many of the latest articles in the database as there are in the feed
$articles = Arsse::$db->feedMatchLatest($feedID, sizeof($items))->getAll();
// perform a first pass matching the latest articles against items in the feed
[$this->newItems, $this->changedItems] = $this->matchItems($items, $articles);
- if (sizeof($this->newItems) && sizeof($items) <= sizeof($articles)) {
+ if (sizeof($this->newItems)) {
// if we need to, perform a second pass on the database looking specifically for IDs and hashes of the new items
$ids = $hashesUT = $hashesUC = $hashesTC = [];
foreach ($this->newItems as $i) {
@@ -282,7 +308,6 @@ class Feed {
// merge the two change-lists, preserving keys
$this->changedItems = array_combine(array_merge(array_keys($this->changedItems), array_keys($changed)), array_merge($this->changedItems, $changed));
}
- return true;
}
protected function matchItems(array $items, array $articles): array {
@@ -417,15 +442,28 @@ class Feed {
return $dates;
}
- protected function scrape(): bool {
+ protected function scrape(): void {
$scraper = new Scraper(self::configure());
foreach (array_merge($this->newItems, $this->changedItems) as $item) {
$scraper->setUrl($item->url);
$scraper->execute();
if ($scraper->hasRelevantContent()) {
- $item->content = $scraper->getFilteredContent();
+ $item->scrapedContent = $scraper->getFilteredContent();
}
}
- return true;
+ }
+
+ protected function computeFilterRules(int $feedID): void {
+ $rules = Arsse::$db->feedRulesGet($feedID);
+ foreach ($rules as $user => $r) {
+ $stats = ['new' => [], 'changed' => []];
+ foreach ($this->newItems as $index => $item) {
+ $stats['new'][$index] = Rule::apply($r['keep'], $r['block'], $item->title, $item->categories);
+ }
+ foreach ($this->changedItems as $index => $item) {
+ $stats['changed'][$index] = Rule::apply($r['keep'], $r['block'], $item->title, $item->categories);
+ }
+ $this->filteredItems[$user] = $stats;
+ }
}
}
diff --git a/lib/Feed/Exception.php b/lib/Feed/Exception.php
index 2bf181e6..113d405e 100644
--- a/lib/Feed/Exception.php
+++ b/lib/Feed/Exception.php
@@ -15,30 +15,33 @@ class Exception extends \JKingWeb\Arsse\AbstractException {
protected const CURL_ERROR_MAP = [1 => "invalidUrl",3 => "invalidUrl",5 => "transmissionError","connectionFailed","connectionFailed","transmissionError","forbidden","unauthorized","transmissionError","transmissionError","transmissionError","transmissionError","connectionFailed","connectionFailed","transmissionError","transmissionError","transmissionError","transmissionError","transmissionError","invalidUrl","transmissionError","transmissionError","transmissionError","transmissionError",28 => "timeout","transmissionError","transmissionError","transmissionError","transmissionError","transmissionError",35 => "invalidCertificate","transmissionError","transmissionError","transmissionError","transmissionError",45 => "transmissionError","unauthorized","maxRedirect",52 => "transmissionError","invalidCertificate","invalidCertificate","transmissionError","transmissionError",58 => "invalidCertificate","invalidCertificate","invalidCertificate","transmissionError","invalidUrl","transmissionError","invalidCertificate","transmissionError","invalidCertificate","forbidden","invalidUrl","forbidden","transmissionError",73 => "transmissionError","transmissionError",77 => "invalidCertificate","invalidUrl",90 => "invalidCertificate","invalidCertificate","transmissionError",94 => "unauthorized","transmissionError","connectionFailed"];
protected const HTTP_ERROR_MAP = [401 => "unauthorized",403 => "forbidden",404 => "invalidUrl",408 => "timeout",410 => "invalidUrl",414 => "invalidUrl",451 => "invalidUrl"];
- public function __construct($url, \Throwable $e) {
- if ($e instanceof BadResponseException) {
- $msgID = self::HTTP_ERROR_MAP[$e->getCode()] ?? "transmissionError";
- } elseif ($e instanceof TooManyRedirectsException) {
- $msgID = "maxRedirect";
- } elseif ($e instanceof GuzzleException) {
- $msg = $e->getMessage();
- if (preg_match("/^Error creating resource:/", $msg)) {
- // PHP stream error; the class of error is ambiguous
- $msgID = "transmissionError";
- } elseif (preg_match("/^cURL error (\d+):/", $msg, $match)) {
- $msgID = self::CURL_ERROR_MAP[(int) $match[1]] ?? "internalError";
+ public function __construct(string $msgID = "", $vars = null, \Throwable $e = null) {
+ if ($msgID === "") {
+ assert($e !== null, new \Exception("Expecting Picofeed or Guzzle exception when no message specified."));
+ if ($e instanceof BadResponseException) {
+ $msgID = self::HTTP_ERROR_MAP[$e->getCode()] ?? "transmissionError";
+ } elseif ($e instanceof TooManyRedirectsException) {
+ $msgID = "maxRedirect";
+ } elseif ($e instanceof GuzzleException) {
+ $msg = $e->getMessage();
+ if (preg_match("/^Error creating resource:/", $msg)) {
+ // PHP stream error; the class of error is ambiguous
+ $msgID = "transmissionError";
+ } elseif (preg_match("/^cURL error (\d+):/", $msg, $match)) {
+ $msgID = self::CURL_ERROR_MAP[(int) $match[1]] ?? "internalError";
+ } else {
+ $msgID = "internalError";
+ }
+ } elseif ($e instanceof PicoFeedException) {
+ $className = get_class($e);
+ // Convert the exception thrown by PicoFeed to the one to be thrown here.
+ $msgID = preg_replace('/^PicoFeed\\\(?:Client|Parser|Reader)\\\([A-Za-z]+)Exception$/', '$1', $className);
+ // If the message ID doesn't change then it's unknown.
+ $msgID = ($msgID !== $className) ? lcfirst($msgID) : "internalError";
} else {
$msgID = "internalError";
}
- } elseif ($e instanceof PicoFeedException) {
- $className = get_class($e);
- // Convert the exception thrown by PicoFeed to the one to be thrown here.
- $msgID = preg_replace('/^PicoFeed\\\(?:Client|Parser|Reader)\\\([A-Za-z]+)Exception$/', '$1', $className);
- // If the message ID doesn't change then it's unknown.
- $msgID = ($msgID !== $className) ? lcfirst($msgID) : "internalError";
- } else {
- $msgID = "internalError";
}
- parent::__construct($msgID, ['url' => $url], $e);
+ parent::__construct($msgID, $vars, $e);
}
}
diff --git a/lib/ImportExport/AbstractImportExport.php b/lib/ImportExport/AbstractImportExport.php
index 22c1f2b1..6f0496fd 100644
--- a/lib/ImportExport/AbstractImportExport.php
+++ b/lib/ImportExport/AbstractImportExport.php
@@ -9,11 +9,11 @@ namespace JKingWeb\Arsse\ImportExport;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Db\ExceptionInput as InputException;
-use JKingWeb\Arsse\User\Exception as UserException;
+use JKingWeb\Arsse\User\ExceptionConflict as UserException;
abstract class AbstractImportExport {
public function import(string $user, string $data, bool $flat = false, bool $replace = false): bool {
- if (!Arsse::$user->exists($user)) {
+ if (!Arsse::$db->userExists($user)) {
throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
// first extract useful information from the input
diff --git a/lib/ImportExport/OPML.php b/lib/ImportExport/OPML.php
index 30a3cc51..85d136cf 100644
--- a/lib/ImportExport/OPML.php
+++ b/lib/ImportExport/OPML.php
@@ -7,7 +7,7 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\ImportExport;
use JKingWeb\Arsse\Arsse;
-use JKingWeb\Arsse\User\Exception as UserException;
+use JKingWeb\Arsse\User\ExceptionConflict as UserException;
class OPML extends AbstractImportExport {
protected function parse(string $opml, bool $flat): array {
@@ -91,7 +91,7 @@ class OPML extends AbstractImportExport {
}
public function export(string $user, bool $flat = false): string {
- if (!Arsse::$user->exists($user)) {
+ if (!Arsse::$db->userExists($user)) {
throw new UserException("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
}
$tags = [];
diff --git a/lib/Misc/Date.php b/lib/Misc/Date.php
index 6972ea5e..6384f4f3 100644
--- a/lib/Misc/Date.php
+++ b/lib/Misc/Date.php
@@ -6,7 +6,7 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\Misc;
-class Date {
+abstract class Date {
public static function transform($date, string $outFormat = null, string $inFormat = null) {
$date = ValueInfo::normalize($date, ValueInfo::T_DATE, $inFormat);
if (!$date) {
diff --git a/lib/Misc/HTTP.php b/lib/Misc/HTTP.php
index 15bfce9b..ac415062 100644
--- a/lib/Misc/HTTP.php
+++ b/lib/Misc/HTTP.php
@@ -12,7 +12,7 @@ class HTTP {
public static function matchType(MessageInterface $msg, string ...$type): bool {
$header = $msg->getHeaderLine("Content-Type") ?? "";
foreach ($type as $t) {
- $pattern = "/^".preg_quote(trim($t), "/")."\s*($|;|,)/i";
+ $pattern = "/^".preg_quote(trim($t), "/")."\s*($|;|,)/Di";
if (preg_match($pattern, $header)) {
return true;
}
diff --git a/lib/Misc/URL.php b/lib/Misc/URL.php
index 42d60194..a80d0aa3 100644
--- a/lib/Misc/URL.php
+++ b/lib/Misc/URL.php
@@ -85,7 +85,7 @@ class URL {
if ($c === "%") {
// the % character signals an encoded character...
$d = substr($part, $pos + 1, 2);
- if (!preg_match("/^[0-9a-fA-F]{2}$/", $d)) {
+ if (!preg_match("/^[0-9a-fA-F]{2}$/D", $d)) {
// unless there are fewer than two characters left in the string or the two characters are not hex digits
$d = ord($c);
} else {
diff --git a/lib/Misc/ValueInfo.php b/lib/Misc/ValueInfo.php
index e9af5272..0aba7700 100644
--- a/lib/Misc/ValueInfo.php
+++ b/lib/Misc/ValueInfo.php
@@ -35,6 +35,17 @@ class ValueInfo {
public const M_DROP = 1 << 29; // drop the value (return null) if the type doesn't match
public const M_STRICT = 1 << 30; // throw an exception if the type doesn't match
public const M_ARRAY = 1 << 31; // the value should be a flat array of values of the specified type; indexed and associative are both acceptable
+ public const TYPE_NAMES = [
+ self::T_MIXED => "mixed",
+ self::T_NULL => "null",
+ self::T_BOOL => "boolean",
+ self::T_INT => "integer",
+ self::T_FLOAT => "float",
+ self::T_DATE => "date",
+ self::T_STRING => "string",
+ self::T_ARRAY => "array",
+ self::T_INTERVAL => "interval",
+ ];
// symbolic date and time formats
protected const DATE_FORMATS = [ // in out
'iso8601' => ["!Y-m-d\TH:i:s", "Y-m-d\TH:i:s\Z" ], // NOTE: ISO 8601 dates require special input processing because of varying formats for timezone offsets
@@ -211,7 +222,7 @@ class ValueInfo {
return $out;
} else {
$out = sprintf("%F", $value);
- return preg_match("/\.0{1,}$/", $out) ? (string) (int) $out : $out;
+ return preg_match("/\.0{1,}$/D", $out) ? (string) (int) $out : $out;
}
}
$info = self::str($value);
@@ -245,7 +256,7 @@ class ValueInfo {
$out = false;
if ($dateInFormat === "microtime") {
// PHP is not able to correctly handle the output of microtime() as the input of DateTime::createFromFormat(), so we fudge it to look like a float
- if (preg_match("<^0\.\d{6}00 \d+$>", $value)) {
+ if (preg_match("<^0\.\d{6}00 \d+$>D", $value)) {
$value = substr($value, 11).".".substr($value, 2, 6);
} else {
throw new \Exception;
diff --git a/lib/REST.php b/lib/REST.php
index bf14369b..349d711e 100644
--- a/lib/REST.php
+++ b/lib/REST.php
@@ -51,6 +51,21 @@ class REST {
'strip' => "/u/",
'class' => REST\Microsub\Auth::class,
],
+ 'miniflux' => [ // Miniflux https://miniflux.app/docs/api.html
+ 'match' => '/v1/',
+ 'strip' => '/v1',
+ 'class' => REST\Miniflux\V1::class,
+ ],
+ 'miniflux-version' => [ // Miniflux version report
+ 'match' => '/version',
+ 'strip' => '',
+ 'class' => REST\Miniflux\Status::class,
+ ],
+ 'miniflux-healthcheck' => [ // Miniflux health check
+ 'match' => '/healthcheck',
+ 'strip' => '',
+ 'class' => REST\Miniflux\Status::class,
+ ],
// Other candidates:
// Google Reader http://feedhq.readthedocs.io/en/latest/api/index.html
// Feedbin v2 https://github.com/feedbin/feedbin-api
@@ -58,7 +73,6 @@ class REST {
// Selfoss https://github.com/SSilence/selfoss/wiki/Restful-API-for-Apps-or-any-other-external-access
// NewsBlur http://www.newsblur.com/api
// Unclear if clients exist:
- // Miniflux https://docs.miniflux.app/en/latest/api.html#api-reference
// Nextcloud News v2 https://github.com/nextcloud/news/blob/master/docs/externalapi/External-Api.md
// BirdReader https://github.com/glynnbird/birdreader/blob/master/API.md
// Feedbin v1 https://github.com/feedbin/feedbin-api/commit/86da10aac5f1a57531a6e17b08744e5f9e7db8a9
@@ -76,17 +90,19 @@ class REST {
}
public function dispatch(ServerRequestInterface $req = null): ResponseInterface {
- // create a request object if not provided
- $req = $req ?? ServerRequestFactory::fromGlobals();
- // find the API to handle
try {
- [$api, $target, $class] = $this->apiMatch($req->getRequestTarget(), $this->apis);
+ // ensure the require extensions are loaded
+ Arsse::checkExtensions(...Arsse::REQUIRED_EXTENSIONS);
+ // create a request object if not provided
+ $req = $req ?? ServerRequestFactory::fromGlobals();
+ // find the API to handle
+ [, $target, $class] = $this->apiMatch($req->getRequestTarget(), $this->apis);
// authenticate the request pre-emptively
$req = $this->authenticateRequest($req);
// modify the request to have an uppercase method and a stripped target
$req = $req->withMethod(strtoupper($req->getMethod()))->withRequestTarget($target);
// fetch the correct handler
- $drv = $this->getHandler($class);
+ $drv = Arsse::$obj->get($class);
// generate a response
if ($req->getMethod() === "HEAD") {
// if the request is a HEAD request, we act exactly as if it were a GET request, and simply remove the response body later
@@ -101,11 +117,6 @@ class REST {
return $this->normalizeResponse($res, $req);
}
- public function getHandler(string $className): REST\Handler {
- // instantiate the API handler
- return new $className();
- }
-
public function apiMatch(string $url): array {
$map = $this->apis;
// sort the API list so the longest URL prefixes come first
@@ -119,7 +130,7 @@ class REST {
// first try a simple substring match
if (strpos($url, $api['match']) === 0) {
// if it matches, perform a more rigorous match and then strip off any defined prefix
- $pattern = "<^".preg_quote($api['match'])."([/\?#]|$)>";
+ $pattern = "<^".preg_quote($api['match'])."([/\?#]|$)>D";
if ($url === $api['match'] || in_array(substr($api['match'], -1, 1), ["/", "?", "#"]) || preg_match($pattern, $url)) {
$target = substr($url, strlen($api['strip']));
} else {
@@ -262,7 +273,7 @@ class REST {
// if the origin is the special value "null", use it
return "null";
}
- if (preg_match("<^([^:]+)://(\[[^\]]+\]|[^\[\]:/\?#@]+)((?::.*)?)$>i", $origin, $match)) {
+ if (preg_match("<^([^:]+)://(\[[^\]]+\]|[^\[\]:/\?#@]+)((?::.*)?)$>Di", $origin, $match)) {
// if the origin sort-of matches the syntax in a general sense, continue
$scheme = $match[1];
$host = $match[2];
@@ -270,7 +281,7 @@ class REST {
// decode and normalize the scheme and port (the port may be blank)
$scheme = strtolower(rawurldecode($scheme));
$port = rawurldecode($port);
- if (!preg_match("<^(?::[0-9]+)?$>", $port) || !preg_match("<^[a-z](?:[a-z0-9\+\-\.])*$>", $scheme)) {
+ if (!preg_match("<^(?::[0-9]+)?$>D", $port) || !preg_match("<^[a-z](?:[a-z0-9\+\-\.])*$>D", $scheme)) {
// if the normalized port contains anything but numbers, or the scheme does not follow the generic URL syntax, the origin is invalid
return "";
}
diff --git a/lib/REST/AbstractHandler.php b/lib/REST/AbstractHandler.php
index 6060da42..2dadfa91 100644
--- a/lib/REST/AbstractHandler.php
+++ b/lib/REST/AbstractHandler.php
@@ -6,8 +6,8 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\REST;
+use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Misc\Date;
-use JKingWeb\Arsse\Misc\ValueInfo;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
@@ -15,6 +15,14 @@ abstract class AbstractHandler implements Handler {
abstract public function __construct();
abstract public function dispatch(ServerRequestInterface $req): ResponseInterface;
+ protected function now(): \DateTimeImmutable {
+ return Arsse::$obj->get(\DateTimeImmutable::class)->setTimezone(new \DateTimeZone("UTC"));
+ }
+
+ protected function isAdmin(): bool {
+ return (bool) Arsse::$user->propertiesGet(Arsse::$user->id, false)['admin'];
+ }
+
protected function fieldMapNames(array $data, array $map): array {
$out = [];
foreach ($map as $to => $from) {
@@ -37,16 +45,4 @@ abstract class AbstractHandler implements Handler {
}
return $data;
}
-
- protected function normalizeInput(array $data, array $types, string $dateFormat = null, int $mode = 0): array {
- $out = [];
- foreach ($types as $key => $type) {
- if (isset($data[$key])) {
- $out[$key] = ValueInfo::normalize($data[$key], $type | $mode, $dateFormat);
- } else {
- $out[$key] = null;
- }
- }
- return $out;
- }
}
diff --git a/lib/REST/Fever/API.php b/lib/REST/Fever/API.php
index 1901397f..20e6c356 100644
--- a/lib/REST/Fever/API.php
+++ b/lib/REST/Fever/API.php
@@ -72,9 +72,6 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
]);
case "GET": // HTTP violation required for client "Unread" on iOS
case "POST":
- if (!HTTP::matchType($req, "", ...self::ACCEPTED_TYPES)) {
- return new EmptyResponse(415, ['Accept' => implode(", ", self::ACCEPTED_TYPES)]);
- }
$out = [
'api_version' => self::LEVEL,
'auth' => 0,
@@ -150,28 +147,21 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
$out['feeds_groups'] = $this->getRelationships();
}
if ($G['favicons']) {
- // TODO: implement favicons properly
- // we provide a single blank favicon for now
- $out['favicons'] = [
- [
- 'id' => 0,
- 'data' => self::GENERIC_ICON_TYPE.",".self::GENERIC_ICON_DATA,
- ],
- ];
+ $out['favicons'] = $this->getIcons();
}
if ($G['items']) {
$out['items'] = $this->getItems($G);
- $out['total_items'] = Arsse::$db->articleCount(Arsse::$user->id);
+ $out['total_items'] = Arsse::$db->articleCount(Arsse::$user->id, (new Context)->hidden(false));
}
if ($G['links']) {
// TODO: implement hot links
$out['links'] = [];
}
if ($G['unread_item_ids'] || $listUnread) {
- $out['unread_item_ids'] = $this->getItemIds((new Context)->unread(true));
+ $out['unread_item_ids'] = $this->getItemIds((new Context)->unread(true)->hidden(false));
}
if ($G['saved_item_ids'] || $listSaved) {
- $out['saved_item_ids'] = $this->getItemIds((new Context)->starred(true));
+ $out['saved_item_ids'] = $this->getItemIds((new Context)->starred(true)->hidden(false));
}
return $out;
}
@@ -241,7 +231,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
try {
// verify the supplied hash is valid
$s = Arsse::$db->TokenLookup("fever.login", $hash);
- } catch (\JKingWeb\Arsse\Db\ExceptionInput $e) {
+ } catch (ExceptionInput $e) {
return false;
}
// set the user name
@@ -263,17 +253,18 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
case "group":
if ($id > 0) {
// concrete groups
- $c->tag($id);
+ $c->tag($id)->hidden(false);
} elseif ($id < 0) {
// group negative-one is the "Sparks" supergroup i.e. no feeds
$c->not->folder(0);
} else {
// group zero is the "Kindling" supergroup i.e. all feeds
- // nothing need to be done for this
+ // only exclude hidden articles
+ $c->hidden(false);
}
break;
case "feed":
- $c->subscription($id);
+ $c->subscription($id)->hidden(false);
break;
default:
return $listSaved;
@@ -308,7 +299,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
protected function setUnread(): void {
- $lastUnread = Arsse::$db->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->getValue();
+ $lastUnread = Arsse::$db->articleList(Arsse::$user->id, (new Context)->hidden(false)->limit(1), ["marked_date"], ["marked_date desc"])->getValue();
if (!$lastUnread) {
// there are no articles
return;
@@ -316,7 +307,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// Fever takes the date of the last read article less fifteen seconds as a cut-off.
// We take the date of last mark (whether it be read, unread, saved, unsaved), which
// may not actually signify a mark, but we'll otherwise also count back fifteen seconds
- $c = new Context;
+ $c = (new Context)->hidden(false);
$lastUnread = Date::normalize($lastUnread, "sql");
$since = Date::sub("PT15S", $lastUnread);
$c->unread(false)->markedSince($since);
@@ -332,7 +323,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
foreach (arsse::$db->subscriptionList(Arsse::$user->id) as $sub) {
$out[] = [
'id' => (int) $sub['id'],
- 'favicon_id' => 0, // TODO: implement favicons
+ 'favicon_id' => (int) $sub['icon_id'],
'title' => (string) $sub['title'],
'url' => $sub['url'],
'site_url' => $sub['source'],
@@ -343,6 +334,24 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
return $out;
}
+ protected function getIcons(): array {
+ $out = [
+ [
+ 'id' => 0,
+ 'data' => self::GENERIC_ICON_TYPE.",".self::GENERIC_ICON_DATA,
+ ],
+ ];
+ foreach (Arsse::$db->iconList(Arsse::$user->id) as $icon) {
+ if ($icon['data']) {
+ $out[] = [
+ 'id' => (int) $icon['id'],
+ 'data' => ($icon['type'] ?: "application/octet-stream").";base64,".base64_encode($icon['data']),
+ ];
+ }
+ }
+ return $out;
+ }
+
protected function getGroups(): array {
$out = [];
foreach (Arsse::$db->tagList(Arsse::$user->id) as $member) {
@@ -373,11 +382,11 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
protected function getItems(array $G): array {
- $c = (new Context)->limit(50);
+ $c = (new Context)->hidden(false)->limit(50);
$reverse = false;
// handle the standard options
if ($G['with_ids']) {
- $c->articles(explode(",", $G['with_ids']));
+ $c->articles(explode(",", $G['with_ids']))->hidden(null);
} elseif ($G['max_id']) {
$c->latestArticle($G['max_id'] - 1);
$reverse = true;
@@ -410,7 +419,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
return $out;
}
- protected function getItemIds(Context $c = null): string {
+ protected function getItemIds(Context $c): string {
$out = [];
foreach (Arsse::$db->articleList(Arsse::$user->id, $c) as $r) {
$out[] = (int) $r['id'];
diff --git a/lib/REST/Miniflux/ErrorResponse.php b/lib/REST/Miniflux/ErrorResponse.php
new file mode 100644
index 00000000..1cf467ee
--- /dev/null
+++ b/lib/REST/Miniflux/ErrorResponse.php
@@ -0,0 +1,19 @@
+ Arsse::$lang->msg("API.Miniflux.Error.".$msg, $data)];
+ parent::__construct($data, $status, $headers, $encodingOptions);
+ }
+}
diff --git a/lib/REST/Miniflux/Status.php b/lib/REST/Miniflux/Status.php
new file mode 100644
index 00000000..367a7a65
--- /dev/null
+++ b/lib/REST/Miniflux/Status.php
@@ -0,0 +1,37 @@
+getRequestTarget())['path'] ?? "";
+ if (!in_array($target, ["/version", "/healthcheck"])) {
+ return new EmptyResponse(404);
+ }
+ $method = $req->getMethod();
+ if ($method === "OPTIONS") {
+ return new EmptyResponse(204, ['Allow' => "HEAD, GET"]);
+ } elseif ($method !== "GET") {
+ return new EmptyResponse(405, ['Allow' => "HEAD, GET"]);
+ }
+ $out = "";
+ if ($target === "/version") {
+ $out = V1::VERSION;
+ } elseif ($target === "/healthcheck") {
+ $out = "OK";
+ }
+ return new TextResponse($out);
+ }
+}
diff --git a/lib/REST/Miniflux/Token.php b/lib/REST/Miniflux/Token.php
new file mode 100644
index 00000000..e249182e
--- /dev/null
+++ b/lib/REST/Miniflux/Token.php
@@ -0,0 +1,31 @@
+tokenCreate($user, "miniflux.login", $t, null, $label);
+ }
+
+ public function tokenList(string $user): array {
+ if (!Arsse::$db->userExists($user)) {
+ throw new ExceptionConflict("doesNotExist", ["action" => __FUNCTION__, "user" => $user]);
+ }
+ $out = [];
+ foreach (Arsse::$db->tokenList($user, "miniflux.login") as $r) {
+ $out[] = ['label' => $r['data'], 'id' => $r['id']];
+ }
+ return $out;
+ }
+}
diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php
new file mode 100644
index 00000000..7cba4061
--- /dev/null
+++ b/lib/REST/Miniflux/V1.php
@@ -0,0 +1,1206 @@
+ V::T_STRING + V::M_ARRAY,
+ 'offset' => V::T_INT,
+ 'limit' => V::T_INT,
+ 'order' => V::T_STRING,
+ 'direction' => V::T_STRING,
+ 'before' => V::T_DATE, // Unix timestamp
+ 'after' => V::T_DATE, // Unix timestamp
+ 'before_entry_id' => V::T_INT,
+ 'after_entry_id' => V::T_INT,
+ 'starred' => V::T_MIXED, // the presence of the starred key is the only thing considered by Miniflux
+ 'search' => V::T_STRING,
+ 'category_id' => V::T_INT,
+ ];
+ protected const VALID_JSON = [
+ // user properties which map directly to Arsse user metadata are listed separately;
+ // not all these properties are used by our implementation, but they are treated
+ // with the same strictness as in Miniflux to ease cross-compatibility
+ 'url' => "string",
+ 'username' => "string",
+ 'password' => "string",
+ 'user_agent' => "string",
+ 'title' => "string",
+ 'feed_url' => "string",
+ 'category_id' => "integer",
+ 'crawler' => "boolean",
+ 'user_agent' => "string",
+ 'scraper_rules' => "string",
+ 'rewrite_rules' => "string",
+ 'keeplist_rules' => "string",
+ 'blocklist_rules' => "string",
+ 'disabled' => "boolean",
+ 'ignore_http_cache' => "boolean",
+ 'fetch_via_proxy' => "boolean",
+ 'entry_ids' => "array", // this is a special case: it is an array of integers
+ 'status' => "string",
+ ];
+ protected const USER_META_MAP = [
+ // Miniflux ID // Arsse ID Default value
+ 'is_admin' => ["admin", false],
+ 'theme' => ["theme", "light_serif"],
+ 'language' => ["lang", "en_US"],
+ 'timezone' => ["tz", "UTC"],
+ 'entry_sorting_direction' => ["sort_asc", false],
+ 'entries_per_page' => ["page_size", 100],
+ 'keyboard_shortcuts' => ["shortcuts", true],
+ 'show_reading_time' => ["reading_time", true],
+ 'entry_swipe' => ["swipe", true],
+ 'stylesheet' => ["stylesheet", ""],
+ ];
+ /** A map between Miniflux's input properties and our input properties when modifiying feeds
+ *
+ * Miniflux also allows changing the following properties:
+ *
+ * - feed_url
+ * - username
+ * - password
+ * - user_agent
+ * - scraper_rules
+ * - rewrite_rules
+ * - disabled
+ * - ignore_http_cache
+ * - fetch_via_proxy
+ *
+ * These either do not apply because we have no cache or proxy,
+ * or cannot be changed because feeds are deduplicated and changing
+ * how they are fetched is not practical with our implementation.
+ * The properties are still checked for type and syntactic validity
+ * where practical, on the assumption Miniflux would also reject
+ * invalid values.
+ */
+ protected const FEED_META_MAP = [
+ 'title' => "title",
+ 'category_id' => "folder",
+ 'crawler' => "scrape",
+ 'keeplist_rules' => "keep_rule",
+ 'blocklist_rules' => "block_rule",
+ ];
+ protected const ARTICLE_COLUMNS = [
+ "id", "url", "title", "subscription",
+ "author", "fingerprint",
+ "published_date", "modified_date",
+ "starred", "unread", "hidden",
+ "content", "media_url", "media_type",
+ ];
+ protected const CALLS = [ // handler method Admin Path Body Query Required fields
+ '/categories' => [
+ 'GET' => ["getCategories", false, false, false, false, []],
+ 'POST' => ["createCategory", false, false, true, false, ["title"]],
+ ],
+ '/categories/1' => [
+ 'PUT' => ["updateCategory", false, true, true, false, ["title"]], // title is effectively required since no other field can be changed
+ 'DELETE' => ["deleteCategory", false, true, false, false, []],
+ ],
+ '/categories/1/entries' => [
+ 'GET' => ["getCategoryEntries", false, true, false, true, []],
+ ],
+ '/categories/1/entries/1' => [
+ 'GET' => ["getCategoryEntry", false, true, false, false, []],
+ ],
+ '/categories/1/feeds' => [
+ 'GET' => ["getCategoryFeeds", false, true, false, false, []],
+ ],
+ '/categories/1/mark-all-as-read' => [
+ 'PUT' => ["markCategory", false, true, false, false, []],
+ ],
+ '/discover' => [
+ 'POST' => ["discoverSubscriptions", false, false, true, false, ["url"]],
+ ],
+ '/entries' => [
+ 'GET' => ["getEntries", false, false, false, true, []],
+ 'PUT' => ["updateEntries", false, false, true, false, ["entry_ids", "status"]],
+ ],
+ '/entries/1' => [
+ 'GET' => ["getEntry", false, true, false, false, []],
+ ],
+ '/entries/1/bookmark' => [
+ 'PUT' => ["toggleEntryBookmark", false, true, false, false, []],
+ ],
+ '/export' => [
+ 'GET' => ["opmlExport", false, false, false, false, []],
+ ],
+ '/feeds' => [
+ 'GET' => ["getFeeds", false, false, false, false, []],
+ 'POST' => ["createFeed", false, false, true, false, ["feed_url", "category_id"]],
+ ],
+ '/feeds/1' => [
+ 'GET' => ["getFeed", false, true, false, false, []],
+ 'PUT' => ["updateFeed", false, true, true, false, []],
+ 'DELETE' => ["deleteFeed", false, true, false, false, []],
+ ],
+ '/feeds/1/entries' => [
+ 'GET' => ["getFeedEntries", false, true, false, true, []],
+ ],
+ '/feeds/1/entries/1' => [
+ 'GET' => ["getFeedEntry", false, true, false, false, []],
+ ],
+ '/feeds/1/icon' => [
+ 'GET' => ["getFeedIcon", false, true, false, false, []],
+ ],
+ '/feeds/1/mark-all-as-read' => [
+ 'PUT' => ["markFeed", false, true, false, false, []],
+ ],
+ '/feeds/1/refresh' => [
+ 'PUT' => ["refreshFeed", false, true, false, false, []],
+ ],
+ '/feeds/refresh' => [
+ 'PUT' => ["refreshAllFeeds", false, false, false, false, []],
+ ],
+ '/import' => [
+ 'POST' => ["opmlImport", false, false, true, false, []],
+ ],
+ '/me' => [
+ 'GET' => ["getCurrentUser", false, false, false, false, []],
+ ],
+ '/users' => [
+ 'GET' => ["getUsers", true, false, false, false, []],
+ 'POST' => ["createUser", true, false, true, false, ["username", "password"]],
+ ],
+ '/users/1' => [
+ 'GET' => ["getUserByNum", true, true, false, false, []],
+ 'PUT' => ["updateUserByNum", false, true, true, false, []], // requires admin for users other than self
+ 'DELETE' => ["deleteUserByNum", true, true, false, false, []],
+ ],
+ '/users/1/mark-all-as-read' => [
+ 'PUT' => ["markUserByNum", false, true, false, false, []],
+ ],
+ '/users/*' => [
+ 'GET' => ["getUserById", true, true, false, false, []],
+ ],
+ ];
+
+ public function __construct() {
+ }
+
+ protected function authenticate(ServerRequestInterface $req): bool {
+ // first check any tokens; this is what Miniflux does
+ if ($req->hasHeader("X-Auth-Token")) {
+ $t = $req->getHeader("X-Auth-Token")[0]; // consider only the first token
+ if (strlen($t)) { // and only if it is not blank
+ try {
+ $d = Arsse::$db->tokenLookup("miniflux.login", $t);
+ } catch (ExceptionInput $e) {
+ return false;
+ }
+ Arsse::$user->id = $d['user'];
+ return true;
+ }
+ }
+ // next check HTTP auth
+ if ($req->getAttribute("authenticated", false)) {
+ Arsse::$user->id = $req->getAttribute("authenticatedUser");
+ return true;
+ }
+ return false;
+ }
+
+ public function dispatch(ServerRequestInterface $req): ResponseInterface {
+ // get the request path only; this is assumed to already be normalized
+ $target = parse_url($req->getRequestTarget(), \PHP_URL_PATH) ?? "";
+ $method = $req->getMethod();
+ // handle HTTP OPTIONS requests
+ if ($method === "OPTIONS") {
+ return $this->handleHTTPOptions($target);
+ }
+ // try to authenticate
+ if (!$this->authenticate($req)) {
+ return new ErrorResponse("401", 401);
+ }
+ $func = $this->chooseCall($target, $method);
+ if ($func instanceof ResponseInterface) {
+ return $func;
+ } else {
+ [$func, $reqAdmin, $reqPath, $reqBody, $reqQuery, $reqFields] = $func;
+ }
+ if ($reqAdmin && !$this->isAdmin()) {
+ return new ErrorResponse("403", 403);
+ }
+ $args = [];
+ if ($reqPath) {
+ $args[] = explode("/", ltrim($target, "/"));
+ }
+ if ($reqBody) {
+ if ($func === "opmlImport") {
+ $data = (string) $req->getBody();
+ } else {
+ $data = (string) $req->getBody();
+ if (strlen($data)) {
+ $data = @json_decode($data, true);
+ if (json_last_error() !== \JSON_ERROR_NONE) {
+ // if the body could not be parsed as JSON, return "400 Bad Request"
+ return new ErrorResponse(["InvalidBodyJSON", json_last_error_msg()], 400);
+ }
+ } else {
+ $data = [];
+ }
+ $data = $this->normalizeBody((array) $data, $reqFields);
+ if ($data instanceof ResponseInterface) {
+ return $data;
+ }
+ }
+ $args[] = $data;
+ }
+ if ($reqQuery) {
+ $query = $this->normalizeQuery(parse_url($req->getRequestTarget(), \PHP_URL_QUERY) ?? "");
+ if ($query instanceof ResponseInterface) {
+ return $query;
+ }
+ $args[] = $query;
+ }
+ try {
+ return $this->$func(...$args);
+ // @codeCoverageIgnoreStart
+ } catch (Exception $e) {
+ // if there was a REST exception return 400
+ return new EmptyResponse(400);
+ } catch (AbstractException $e) {
+ // if there was any other Arsse exception return 500
+ return new EmptyResponse(500);
+ }
+ // @codeCoverageIgnoreEnd
+ }
+
+ protected function chooseCall(string $url, string $method) {
+ // // normalize the URL path: change any IDs to 1 for easier comparison
+ $url = $this->normalizePathIds($url);
+ // normalize the HTTP method to uppercase
+ $method = strtoupper($method);
+ // we now evaluate the supplied URL against every supported path for the selected scope
+ if (isset(self::CALLS[$url])) {
+ // if the path is supported, make sure the method is allowed
+ if (isset(self::CALLS[$url][$method])) {
+ // if it is allowed, return the object method to run, assuming the method exists
+ assert(method_exists($this, self::CALLS[$url][$method][0]), new \Exception("Method is not implemented"));
+ return self::CALLS[$url][$method];
+ } else {
+ // otherwise return 405
+ return new EmptyResponse(405, ['Allow' => implode(", ", array_keys(self::CALLS[$url]))]);
+ }
+ } else {
+ // if the path is not supported, return 404
+ return new EmptyResponse(404);
+ }
+ }
+
+ protected function normalizePathIds(string $url): string {
+ $path = explode("/", $url);
+ // any path components which are database IDs (integers greater than zero) should be replaced with "1", for easier comparison (we don't care about the specific ID)
+ for ($a = 0; $a < sizeof($path); $a++) {
+ if (V::id($path[$a])) {
+ $path[$a] = "1";
+ }
+ }
+ // handle special case "Get User By User Name", which can have any non-numeric string, non-empty as the last component
+ if (sizeof($path) === 3 && $path[0] === "" && $path[1] === "users" && !preg_match("/^(?:\d+)?$/D", $path[2])) {
+ $path[2] = "*";
+ }
+ return implode("/", $path);
+ }
+
+ protected function normalizeBody(array $body, array $req) {
+ // Miniflux does not attempt to coerce values into different types
+ foreach (self::VALID_JSON as $k => $t) {
+ if (!isset($body[$k])) {
+ $body[$k] = null;
+ } elseif (gettype($body[$k]) !== $t) {
+ return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])], 422);
+ } elseif (
+ (in_array($k, ["keeplist_rules", "blocklist_rules"]) && !Rule::validate($body[$k]))
+ || (in_array($k, ["url", "feed_url"]) && !URL::absolute($body[$k]))
+ || ($k === "category_id" && $body[$k] < 1)
+ || ($k === "status" && !in_array($body[$k], ["read", "unread", "removed"]))
+ ) {
+ return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422);
+ } elseif ($k === "entry_ids") {
+ foreach ($body[$k] as $v) {
+ if (gettype($v) !== "integer") {
+ return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => "integer", 'actual' => gettype($v)], 422);
+ } elseif ($v < 1) {
+ return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422);
+ }
+ }
+ }
+ }
+ //normalize user-specific input
+ foreach (self::USER_META_MAP as $k => [,$d]) {
+ $t = gettype($d);
+ if (!isset($body[$k])) {
+ $body[$k] = null;
+ } elseif ($k === "entry_sorting_direction") {
+ if (!in_array($body[$k], ["asc", "desc"])) {
+ return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422);
+ }
+ } elseif (gettype($body[$k]) !== $t) {
+ return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => $t, 'actual' => gettype($body[$k])], 422);
+ }
+ }
+ // check for any missing required values
+ foreach ($req as $k) {
+ if (!isset($body[$k]) || (is_array($body[$k]) && !$body[$k])) {
+ return new ErrorResponse(["MissingInputValue", 'field' => $k], 422);
+ }
+ }
+ return $body;
+ }
+
+ protected function normalizeQuery(string $query) {
+ // fill an array with all valid keys
+ $out = [];
+ $seen = [];
+ foreach (self::VALID_QUERY as $k => $t) {
+ $out[$k] = ($t >= V::M_ARRAY) ? [] : null;
+ $seen[$k] = false;
+ }
+ // split the query string and normalize the values to their correct types
+ foreach (explode("&", $query) as $parts) {
+ $parts = explode("=", $parts, 2);
+ $k = rawurldecode($parts[0]);
+ $v = (isset($parts[1])) ? rawurldecode($parts[1]) : "";
+ if (!isset(self::VALID_QUERY[$k])) {
+ // ignore unknown keys
+ continue;
+ }
+ $t = self::VALID_QUERY[$k] & ~V::M_ARRAY;
+ $a = self::VALID_QUERY[$k] >= V::M_ARRAY;
+ try {
+ if ($seen[$k] && !$a) {
+ // if the key has already been seen and it's not an array field, bail
+ // NOTE: Miniflux itself simply ignores duplicates entirely
+ return new ErrorResponse(["DuplicateInputValue", 'field' => $k], 400);
+ }
+ $seen[$k] = true;
+ if ($k === "starred") {
+ // the starred key is a special case in that Miniflux only considers the presence of the key
+ $out[$k] = true;
+ continue;
+ } elseif ($v === "") {
+ // if the value is empty we can discard the value, but subsequent values for the same non-array key are still considered duplicates
+ continue;
+ } elseif ($a) {
+ $out[$k][] = V::normalize($v, $t + V::M_STRICT, "unix");
+ } else {
+ $out[$k] = V::normalize($v, $t + V::M_STRICT, "unix");
+ }
+ } catch (ExceptionType $e) {
+ return new ErrorResponse(["InvalidInputValue", 'field' => $k], 400);
+ }
+ // perform additional validation
+ if (
+ (in_array($k, ["category_id", "before_entry_id", "after_entry_id"]) && $v < 1)
+ || (in_array($k, ["limit", "offset"]) && $v < 0)
+ || ($k === "direction" && !in_array($v, ["asc", "desc"]))
+ || ($k === "order" && !in_array($v, ["id", "status", "published_at", "category_title", "category_id"]))
+ || ($k === "status" && !in_array($v, ["read", "unread", "removed"]))
+ ) {
+ return new ErrorResponse(["InvalidInputValue", 'field' => $k], 400);
+ }
+ }
+ return $out;
+ }
+
+ protected function handleHTTPOptions(string $url): ResponseInterface {
+ // normalize the URL path: change any IDs to 1 for easier comparison
+ $url = $this->normalizePathIDs($url);
+ if (isset(self::CALLS[$url])) {
+ // if the path is supported, respond with the allowed methods and other metadata
+ $allowed = array_keys(self::CALLS[$url]);
+ // if GET is allowed, so is HEAD
+ if (in_array("GET", $allowed)) {
+ array_unshift($allowed, "HEAD");
+ }
+ return new EmptyResponse(204, [
+ 'Allow' => implode(", ", $allowed),
+ 'Accept' => implode(", ", $url === "/import" ? self::ACCEPTED_TYPES_OPML : self::ACCEPTED_TYPES_JSON),
+ ]);
+ } else {
+ // if the path is not supported, return 404
+ return new EmptyResponse(404);
+ }
+ }
+
+ protected function listUsers(array $users, bool $reportMissing): array {
+ $out = [];
+ $now = Date::transform($this->now(), "iso8601m");
+ foreach ($users as $u) {
+ try {
+ $info = Arsse::$user->propertiesGet($u, true);
+ } catch (UserException $e) {
+ if ($reportMissing) {
+ throw $e;
+ } else {
+ continue;
+ }
+ }
+ $entry = [
+ 'id' => $info['num'],
+ 'username' => $u,
+ 'last_login_at' => $now,
+ 'google_id' => "",
+ 'openid_connect_id' => "",
+ ];
+ foreach (self::USER_META_MAP as $ext => [$int, $default]) {
+ $entry[$ext] = $info[$int] ?? $default;
+ }
+ $entry['entry_sorting_direction'] = ($entry['entry_sorting_direction']) ? "asc" : "desc";
+ $out[] = $entry;
+ }
+ return $out;
+ }
+
+ protected function editUser(string $user, array $data): array {
+ // map Miniflux properties to internal metadata properties
+ $in = [];
+ foreach (self::USER_META_MAP as $i => [$o]) {
+ if (isset($data[$i])) {
+ if ($i === "entry_sorting_direction") {
+ $in[$o] = $data[$i] === "asc";
+ } else {
+ $in[$o] = $data[$i];
+ }
+ }
+ }
+ // make any requested changes
+ $tr = Arsse::$user->begin();
+ if ($in) {
+ Arsse::$user->propertiesSet($user, $in);
+ }
+ // read out the newly-modified user and commit the changes
+ $out = $this->listUsers([$user], true)[0];
+ $tr->commit();
+ // add the input password if a password change was requested
+ if (isset($data['password'])) {
+ $out['password'] = $data['password'];
+ }
+ return $out;
+ }
+
+ protected function discoverSubscriptions(array $data): ResponseInterface {
+ try {
+ $list = Feed::discoverAll((string) $data['url'], (string) $data['username'], (string) $data['password']);
+ } catch (FeedException $e) {
+ $msg = [
+ 10502 => "Fetch404",
+ 10506 => "Fetch403",
+ 10507 => "Fetch401",
+ 10521 => "Fetch404",
+ ][$e->getCode()] ?? "FetchOther";
+ return new ErrorResponse($msg, 502);
+ }
+ $out = [];
+ foreach ($list as $url) {
+ // TODO: This needs to be refined once PicoFeed is replaced
+ $out[] = ['title' => "Feed", 'type' => "rss", 'url' => $url];
+ }
+ return new Response($out);
+ }
+
+ protected function getUsers(): ResponseInterface {
+ $tr = Arsse::$user->begin();
+ return new Response($this->listUsers(Arsse::$user->list(), false));
+ }
+
+ protected function getUserById(array $path): ResponseInterface {
+ try {
+ return new Response($this->listUsers([$path[1]], true)[0] ?? new \stdClass);
+ } catch (UserException $e) {
+ return new ErrorResponse("404", 404);
+ }
+ }
+
+ protected function getUserByNum(array $path): ResponseInterface {
+ try {
+ $user = Arsse::$user->lookup((int) $path[1]);
+ return new Response($this->listUsers([$user], true)[0] ?? new \stdClass);
+ } catch (UserException $e) {
+ return new ErrorResponse("404", 404);
+ }
+ }
+
+ protected function getCurrentUser(): ResponseInterface {
+ return new Response($this->listUsers([Arsse::$user->id], false)[0] ?? new \stdClass);
+ }
+
+ protected function createUser(array $data): ResponseInterface {
+ try {
+ $tr = Arsse::$user->begin();
+ $data['password'] = Arsse::$user->add($data['username'], $data['password']);
+ $out = $this->editUser($data['username'], $data);
+ $tr->commit();
+ } catch (UserException $e) {
+ switch ($e->getCode()) {
+ case 10403:
+ return new ErrorResponse(["DuplicateUser", 'user' => $data['username']], 409);
+ case 10441:
+ return new ErrorResponse(["InvalidInputValue", 'field' => "timezone"], 422);
+ case 10443:
+ return new ErrorResponse(["InvalidInputValue", 'field' => "entries_per_page"], 422);
+ case 10444:
+ return new ErrorResponse(["InvalidInputValue", 'field' => "username"], 422);
+ }
+ throw $e; // @codeCoverageIgnore
+ }
+ return new Response($out, 201);
+ }
+
+ protected function updateUserByNum(array $path, array $data): ResponseInterface {
+ // this function is restricted to admins unless the affected user and calling user are the same
+ $user = Arsse::$user->propertiesGet(Arsse::$user->id, false);
+ if (((int) $path[1]) === $user['num']) {
+ if ($data['is_admin'] && !$user['admin']) {
+ // non-admins should not be able to set themselves as admin
+ return new ErrorResponse("InvalidElevation", 403);
+ }
+ $user = Arsse::$user->id;
+ } elseif (!$user['admin']) {
+ return new ErrorResponse("403", 403);
+ } else {
+ try {
+ $user = Arsse::$user->lookup((int) $path[1]);
+ } catch (ExceptionConflict $e) {
+ return new ErrorResponse("404", 404);
+ }
+ }
+ // make any requested changes
+ try {
+ $tr = Arsse::$user->begin();
+ if (isset($data['username'])) {
+ Arsse::$user->rename($user, $data['username']);
+ $user = $data['username'];
+ }
+ if (isset($data['password'])) {
+ Arsse::$user->passwordSet($user, $data['password']);
+ }
+ $out = $this->editUser($user, $data);
+ $tr->commit();
+ } catch (UserException $e) {
+ switch ($e->getCode()) {
+ case 10403:
+ return new ErrorResponse(["DuplicateUser", 'user' => $data['username']], 409);
+ case 10441:
+ return new ErrorResponse(["InvalidInputValue", 'field' => "timezone"], 422);
+ case 10443:
+ return new ErrorResponse(["InvalidInputValue", 'field' => "entries_per_page"], 422);
+ case 10444:
+ return new ErrorResponse(["InvalidInputValue", 'field' => "username"], 422);
+ }
+ throw $e; // @codeCoverageIgnore
+ }
+ return new Response($out, 201);
+ }
+
+ protected function deleteUserByNum(array $path): ResponseInterface {
+ try {
+ Arsse::$user->remove(Arsse::$user->lookup((int) $path[1]));
+ } catch (ExceptionConflict $e) {
+ return new ErrorResponse("404", 404);
+ }
+ return new EmptyResponse(204);
+ }
+
+ /** Returns a useful subset of user metadata
+ *
+ * The following keys are included:
+ *
+ * - "num": The user's numeric ID,
+ * - "root": The effective name of the root folder
+ */
+ protected function userMeta(string $user): array {
+ $meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
+ return [
+ 'num' => $meta['num'],
+ 'root' => $meta['root_folder_name'] ?? Arsse::$lang->msg("API.Miniflux.DefaultCategoryName"),
+ 'tz' => new \DateTimeZone($meta['tz'] ?? "UTC"),
+ ];
+ }
+
+ protected function getCategories(): ResponseInterface {
+ $out = [];
+ // add the root folder as a category
+ $meta = $this->userMeta(Arsse::$user->id);
+ $out[] = ['id' => 1, 'title' => $meta['root'], 'user_id' => $meta['num']];
+ // add other top folders as categories
+ foreach (Arsse::$db->folderList(Arsse::$user->id, null, false) as $f) {
+ // always add 1 to the ID since the root folder will always be 1 instead of 0.
+ $out[] = ['id' => $f['id'] + 1, 'title' => $f['name'], 'user_id' => $meta['num']];
+ }
+ return new Response($out);
+ }
+
+ protected function createCategory(array $data): ResponseInterface {
+ try {
+ $id = Arsse::$db->folderAdd(Arsse::$user->id, ['name' => (string) $data['title']]);
+ } catch (ExceptionInput $e) {
+ if ($e->getCode() === 10236) {
+ return new ErrorResponse(["DuplicateCategory", 'title' => $data['title']], 409);
+ } else {
+ return new ErrorResponse(["InvalidCategory", 'title' => $data['title']], 422);
+ }
+ }
+ $meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
+ return new Response(['id' => $id + 1, 'title' => $data['title'], 'user_id' => $meta['num']], 201);
+ }
+
+ protected function updateCategory(array $path, array $data): ResponseInterface {
+ // category IDs in Miniflux are always greater than 1; we have folder 0, so we decrement category IDs by 1 to get the folder ID
+ $folder = $path[1] - 1;
+ $title = $data['title'] ?? "";
+ try {
+ if ($folder === 0) {
+ // folder 0 doesn't actually exist in the database, so its name is kept as user metadata
+ if (!strlen(trim($title))) {
+ throw new ExceptionInput("whitespace");
+ }
+ $title = Arsse::$user->propertiesSet(Arsse::$user->id, ['root_folder_name' => $title])['root_folder_name'];
+ } else {
+ Arsse::$db->folderPropertiesSet(Arsse::$user->id, $folder, ['name' => $title]);
+ }
+ } catch (ExceptionInput $e) {
+ if ($e->getCode() === 10236) {
+ return new ErrorResponse(["DuplicateCategory", 'title' => $title], 409);
+ } elseif (in_array($e->getCode(), [10237, 10239])) {
+ return new ErrorResponse("404", 404);
+ } else {
+ return new ErrorResponse(["InvalidCategory", 'title' => $title], 422);
+ }
+ }
+ $meta = Arsse::$user->propertiesGet(Arsse::$user->id, false);
+ return new Response(['id' => (int) $path[1], 'title' => $title, 'user_id' => $meta['num']], 201);
+ }
+
+ protected function deleteCategory(array $path): ResponseInterface {
+ try {
+ $folder = $path[1] - 1;
+ if ($folder !== 0) {
+ Arsse::$db->folderRemove(Arsse::$user->id, $folder);
+ } else {
+ // if we're deleting from the root folder, delete each child subscription individually
+ // otherwise we'd be deleting the entire tree
+ $tr = Arsse::$db->begin();
+ foreach (Arsse::$db->subscriptionList(Arsse::$user->id, null, false) as $sub) {
+ Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $sub['id']);
+ }
+ $tr->commit();
+ }
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ return new EmptyResponse(204);
+ }
+
+ protected function transformFeed(array $sub, int $uid, string $rootName, \DateTimeZone $tz): array {
+ $url = new Uri($sub['url']);
+ return [
+ 'id' => (int) $sub['id'],
+ 'user_id' => $uid,
+ 'feed_url' => (string) $url->withUserInfo(""),
+ 'site_url' => (string) $sub['source'],
+ 'title' => (string) $sub['title'],
+ 'checked_at' => Date::normalize($sub['updated'], "sql")->setTimezone($tz)->format(self::DATE_FORMAT_MICRO),
+ 'next_check_at' => $sub['next_fetch'] ? Date::normalize($sub['next_fetch'], "sql")->setTimezone($tz)->format(self::DATE_FORMAT_MICRO) : "0001-01-01T00:00:00Z",
+ 'etag_header' => (string) $sub['etag'],
+ 'last_modified_header' => (string) Date::transform($sub['edited'], "http", "sql"),
+ 'parsing_error_message' => (string) $sub['err_msg'],
+ 'parsing_error_count' => (int) $sub['err_count'],
+ 'scraper_rules' => "",
+ 'rewrite_rules' => "",
+ 'crawler' => (bool) $sub['scrape'],
+ 'blocklist_rules' => (string) $sub['block_rule'],
+ 'keeplist_rules' => (string) $sub['keep_rule'],
+ 'user_agent' => "",
+ 'username' => rawurldecode(explode(":", $url->getUserInfo(), 2)[0] ?? ""),
+ 'password' => rawurldecode(explode(":", $url->getUserInfo(), 2)[1] ?? ""),
+ 'disabled' => false,
+ 'ignore_http_cache' => false,
+ 'fetch_via_proxy' => false,
+ 'category' => [
+ 'id' => (int) $sub['top_folder'] + 1,
+ 'title' => $sub['top_folder_name'] ?? $rootName,
+ 'user_id' => $uid,
+ ],
+ 'icon' => $sub['icon_id'] ? ['feed_id' => (int) $sub['id'], 'icon_id' => (int) $sub['icon_id']] : null,
+ ];
+ }
+
+ protected function getFeeds(): ResponseInterface {
+ $out = [];
+ $tr = Arsse::$db->begin();
+ $meta = $this->userMeta(Arsse::$user->id);
+ foreach (Arsse::$db->subscriptionList(Arsse::$user->id) as $r) {
+ $out[] = $this->transformFeed($r, $meta['num'], $meta['root'], $meta['tz']);
+ }
+ return new Response($out);
+ }
+
+ protected function getCategoryFeeds(array $path): ResponseInterface {
+ // transform the category number into a folder number by subtracting one
+ $folder = ((int) $path[1]) - 1;
+ // unless the folder is root, list recursive
+ $recursive = $folder > 0;
+ $out = [];
+ $tr = Arsse::$db->begin();
+ // get the list of subscriptions, or bail
+ try {
+ $meta = $this->userMeta(Arsse::$user->id);
+ foreach (Arsse::$db->subscriptionList(Arsse::$user->id, $folder, $recursive) as $r) {
+ $out[] = $this->transformFeed($r, $meta['num'], $meta['root'], $meta['tz']);
+ }
+ } catch (ExceptionInput $e) {
+ // the folder does not exist
+ return new ErrorResponse("404", 404);
+ }
+ return new Response($out);
+ }
+
+ protected function getFeed(array $path): ResponseInterface {
+ $tr = Arsse::$db->begin();
+ $meta = $this->userMeta(Arsse::$user->id);
+ try {
+ $sub = Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, (int) $path[1]);
+ return new Response($this->transformFeed($sub, $meta['num'], $meta['root'], $meta['tz']));
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ }
+
+ protected function createFeed(array $data): ResponseInterface {
+ try {
+ Arsse::$db->feedAdd($data['feed_url'], (string) $data['username'], (string) $data['password'], false, (bool) $data['crawler']);
+ $tr = Arsse::$db->begin();
+ $id = Arsse::$db->subscriptionAdd(Arsse::$user->id, $data['feed_url'], (string) $data['username'], (string) $data['password'], false, (bool) $data['crawler']);
+ Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $id, ['folder' => $data['category_id'] - 1, 'scrape' => (bool) $data['crawler']]);
+ $tr->commit();
+ if (strlen($data['keeplist_rules'] ?? "") || strlen($data['blocklist_rules'] ?? "")) {
+ // we do rules separately so as not to tie up the database
+ Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, $id, ['keep_rule' => $data['keeplist_rules'], 'block_rule' => $data['blocklist_rules']]);
+ }
+ } catch (FeedException $e) {
+ $msg = [
+ 10502 => "Fetch404",
+ 10506 => "Fetch403",
+ 10507 => "Fetch401",
+ 10521 => "Fetch404",
+ 10522 => "FetchFormat",
+ ][$e->getCode()] ?? "FetchOther";
+ return new ErrorResponse($msg, 502);
+ } catch (ExceptionInput $e) {
+ switch ($e->getCode()) {
+ case 10235:
+ return new ErrorResponse("MissingCategory", 422);
+ case 10236:
+ return new ErrorResponse("DuplicateFeed", 409);
+ }
+ }
+ return new Response(['feed_id' => $id], 201);
+ }
+
+ protected function updateFeed(array $path, array $data): ResponseInterface {
+ $in = [];
+ foreach (self::FEED_META_MAP as $from => $to) {
+ if (isset($data[$from])) {
+ $in[$to] = $data[$from];
+ }
+ }
+ if (isset($in['folder'])) {
+ $in['folder'] -= 1;
+ }
+ try {
+ Arsse::$db->subscriptionPropertiesSet(Arsse::$user->id, (int) $path[1], $in);
+ } catch (ExceptionInput $e) {
+ switch ($e->getCode()) {
+ case 10231:
+ case 10232:
+ return new ErrorResponse("InvalidTitle", 422);
+ case 10235:
+ return new ErrorResponse("MissingCategory", 422);
+ case 10239:
+ return new ErrorResponse("404", 404);
+ }
+ }
+ return $this->getFeed($path)->withStatus(201);
+ }
+
+ protected function deleteFeed(array $path): ResponseInterface {
+ try {
+ Arsse::$db->subscriptionRemove(Arsse::$user->id, (int) $path[1]);
+ return new EmptyResponse(204);
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ }
+
+ protected function getFeedIcon(array $path): ResponseInterface {
+ try {
+ $icon = Arsse::$db->subscriptionIcon(Arsse::$user->id, (int) $path[1]);
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ if (!$icon || !$icon['type'] || !$icon['data']) {
+ return new ErrorResponse("404", 404);
+ }
+ return new Response([
+ 'id' => (int) $icon['id'],
+ 'data' => $icon['type'].";base64,".base64_encode($icon['data']),
+ 'mime_type' => $icon['type'],
+ ]);
+ }
+
+ protected function computeContext(array $query, Context $c = null): Context {
+ if ($query['before'] && $query['before']->getTimestamp() === 0) {
+ $query['before'] = null; // NOTE: This workaround is needed for compatibility with "Microflux for Miniflux", an Android Client
+ }
+ $c = ($c ?? new Context)
+ ->limit($query['limit'] ?? self::DEFAULT_ENTRY_LIMIT) // NOTE: This does not honour user preferences
+ ->offset($query['offset'])
+ ->starred($query['starred'])
+ ->modifiedSince($query['after']) // FIXME: This may not be the correct date field
+ ->notModifiedSince($query['before'])
+ ->oldestArticle($query['after_entry_id'] ? $query['after_entry_id'] + 1 : null) // FIXME: This might be edition
+ ->latestArticle($query['before_entry_id'] ? $query['before_entry_id'] - 1 : null)
+ ->searchTerms(strlen($query['search'] ?? "") ? preg_split("/\s+/", $query['search']) : null); // NOTE: Miniflux matches only whole words; we match simple substrings
+ if ($query['category_id']) {
+ if ($query['category_id'] === 1) {
+ $c->folderShallow(0);
+ } else {
+ $c->folder($query['category_id'] - 1);
+ }
+ }
+ // FIXME: specifying e.g. ?status=read&status=removed should yield all hidden articles and all read articles, but the best we can do is all read articles which are or are not hidden
+ $status = array_unique($query['status']);
+ sort($status);
+ if ($status === ["read", "removed"]) {
+ $c->unread(false);
+ } elseif ($status === ["read", "unread"]) {
+ $c->hidden(false);
+ } elseif ($status === ["read"]) {
+ $c->hidden(false)->unread(false);
+ } elseif ($status === ["removed", "unread"]) {
+ $c->unread(true);
+ } elseif ($status === ["removed"]) {
+ $c->hidden(true);
+ } elseif ($status === ["unread"]) {
+ $c->hidden(false)->unread(true);
+ }
+ return $c;
+ }
+
+ protected function computeOrder(array $query): array {
+ $desc = $query['direction'] === "desc" ? " desc" : "";
+ if ($query['order'] === "id") {
+ return ["id".$desc];
+ } elseif ($query['order'] === "status") {
+ if (!$desc) {
+ return ["hidden", "unread desc"];
+ } else {
+ return ["hidden desc", "unread"];
+ }
+ } elseif ($query['order'] === "published_at") {
+ return ["modified_date".$desc];
+ } elseif ($query['order'] === "category_title") {
+ return ["top_folder_name".$desc];
+ } elseif ($query['order'] === "category_id") {
+ return ["top_folder".$desc];
+ } else {
+ return [self::DEFAULT_ORDER_COL.$desc];
+ }
+ }
+
+ protected function transformEntry(array $entry, int $uid, \DateTimeZone $tz): array {
+ if ($entry['hidden']) {
+ $status = "removed";
+ } elseif ($entry['unread']) {
+ $status = "unread";
+ } else {
+ $status = "read";
+ }
+ if ($entry['media_url']) {
+ $enclosures = [
+ [
+ 'id' => (int) $entry['id'], // NOTE: We don't have IDs for enclosures, but we also only have one enclosure per entry, so we can just re-use the same ID
+ 'user_id' => $uid,
+ 'entry_id' => (int) $entry['id'],
+ 'url' => $entry['media_url'],
+ 'mime_type' => $entry['media_type'] ?: "application/octet-stream",
+ 'size' => 0,
+ ],
+ ];
+ } else {
+ $enclosures = null;
+ }
+ return [
+ 'id' => (int) $entry['id'],
+ 'user_id' => $uid,
+ 'feed_id' => (int) $entry['subscription'],
+ 'status' => $status,
+ 'hash' => $entry['fingerprint'],
+ 'title' => $entry['title'],
+ 'url' => $entry['url'],
+ 'comments_url' => "",
+ 'published_at' => Date::normalize($entry['published_date'], "sql")->setTimezone($tz)->format(self::DATE_FORMAT_SEC),
+ 'created_at' => Date::normalize($entry['modified_date'], "sql")->setTimezone($tz)->format(self::DATE_FORMAT_MICRO),
+ 'content' => $entry['content'],
+ 'author' => (string) $entry['author'],
+ 'share_code' => "",
+ 'starred' => (bool) $entry['starred'],
+ 'reading_time' => 0,
+ 'enclosures' => $enclosures,
+ 'feed' => null,
+ ];
+ }
+
+ protected function listEntries(array $query, Context $c): array {
+ $c = $this->computeContext($query, $c);
+ $order = $this->computeOrder($query);
+ $tr = Arsse::$db->begin();
+ $meta = $this->userMeta(Arsse::$user->id);
+ // compile the list of entries
+ $out = [];
+ foreach (Arsse::$db->articleList(Arsse::$user->id, $c, self::ARTICLE_COLUMNS, $order) as $entry) {
+ $out[] = $this->transformEntry($entry, $meta['num'], $meta['tz']);
+ }
+ // next compile a map of feeds to add to the entries
+ if ($out) {
+ $feeds = [];
+ foreach (Arsse::$db->subscriptionList(Arsse::$user->id) as $r) {
+ $feeds[(int) $r['id']] = $this->transformFeed($r, $meta['num'], $meta['root'], $meta['tz']);
+ }
+ // add the feed objects to each entry
+ // NOTE: If ever we implement multiple enclosure, this would be the right place to add them
+ for ($a = 0; $a < sizeof($out); $a++) {
+ $out[$a]['feed'] = $feeds[$out[$a]['feed_id']];
+ }
+ }
+ // finally compute the total number of entries match the query, where necessary
+ $count = sizeof($out);
+ if ($c->offset || ($c->limit && $count >= $c->limit)) {
+ $count = Arsse::$db->articleCount(Arsse::$user->id, (clone $c)->limit(0)->offset(0));
+ }
+ return ['total' => $count, 'entries' => $out];
+ }
+
+ protected function findEntry(int $id, Context $c = null): array {
+ $c = ($c ?? new Context)->article($id);
+ $tr = Arsse::$db->begin();
+ $meta = $this->userMeta(Arsse::$user->id);
+ // find the entry we want
+ $entry = Arsse::$db->articleList(Arsse::$user->id, $c, self::ARTICLE_COLUMNS)->getRow();
+ if (!$entry) {
+ throw new ExceptionInput("idMissing");
+ }
+ $out = $this->transformEntry($entry, $meta['num'], $meta['tz']);
+ // next transform the parent feed of the entry
+ $out['feed'] = $this->transformFeed(Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, $out['feed_id']), $meta['num'], $meta['root'], $meta['tz']);
+ return $out;
+ }
+
+ protected function getEntries(array $query): ResponseInterface {
+ try {
+ return new Response($this->listEntries($query, new Context));
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("MissingCategory", 400);
+ }
+ }
+
+ protected function getFeedEntries(array $path, array $query): ResponseInterface {
+ $c = (new Context)->subscription((int) $path[1]);
+ try {
+ return new Response($this->listEntries($query, $c));
+ } catch (ExceptionInput $e) {
+ // FIXME: this should differentiate between a missing feed and a missing category, but doesn't
+ return new ErrorResponse("404", 404);
+ }
+ }
+
+ protected function getCategoryEntries(array $path, array $query): ResponseInterface {
+ $query['category_id'] = (int) $path[1];
+ try {
+ return new Response($this->listEntries($query, new Context));
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ }
+
+ protected function getEntry(array $path): ResponseInterface {
+ try {
+ return new Response($this->findEntry((int) $path[1]));
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ }
+
+ protected function getFeedEntry(array $path): ResponseInterface {
+ $c = (new Context)->subscription((int) $path[1]);
+ try {
+ return new Response($this->findEntry((int) $path[3], $c));
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ }
+
+ protected function getCategoryEntry(array $path): ResponseInterface {
+ $c = new Context;
+ if ($path[1] === "1") {
+ $c->folderShallow(0);
+ } else {
+ $c->folder((int) $path[1] - 1);
+ }
+ try {
+ return new Response($this->findEntry((int) $path[3], $c));
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ }
+
+ protected function updateEntries(array $data): ResponseInterface {
+ if ($data['status'] === "read") {
+ $in = ['read' => true, 'hidden' => false];
+ } elseif ($data['status'] === "unread") {
+ $in = ['read' => false, 'hidden' => false];
+ } elseif ($data['status'] === "removed") {
+ $in = ['read' => true, 'hidden' => true];
+ }
+ assert(isset($in), new \Exception("Unknown status specified"));
+ Arsse::$db->articleMark(Arsse::$user->id, $in, (new Context)->articles($data['entry_ids']));
+ return new EmptyResponse(204);
+ }
+
+ protected function massRead(Context $c): void {
+ Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c->hidden(false));
+ }
+
+ protected function markUserByNum(array $path): ResponseInterface {
+ // this function is restricted to the logged-in user
+ $user = Arsse::$user->propertiesGet(Arsse::$user->id, false);
+ if (((int) $path[1]) !== $user['num']) {
+ return new ErrorResponse("403", 403);
+ }
+ $this->massRead(new Context);
+ return new EmptyResponse(204);
+ }
+
+ protected function markFeed(array $path): ResponseInterface {
+ try {
+ $this->massRead((new Context)->subscription((int) $path[1]));
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ return new EmptyResponse(204);
+ }
+
+ protected function markCategory(array $path): ResponseInterface {
+ $folder = $path[1] - 1;
+ $c = new Context;
+ if ($folder === 0) {
+ // if we're marking the root folder don't also mark its child folders, since Miniflux organizes it as a peer of other folders
+ $c->folderShallow($folder);
+ } else {
+ $c->folder($folder);
+ }
+ try {
+ $this->massRead($c);
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ return new EmptyResponse(204);
+ }
+
+ protected function toggleEntryBookmark(array $path): ResponseInterface {
+ // NOTE: A toggle is bad design, but we have no choice but to implement what Miniflux does
+ $id = (int) $path[1];
+ $c = (new Context)->article($id);
+ try {
+ $tr = Arsse::$db->begin();
+ if (Arsse::$db->articleCount(Arsse::$user->id, (clone $c)->starred(false))) {
+ Arsse::$db->articleMark(Arsse::$user->id, ['starred' => true], $c);
+ } else {
+ Arsse::$db->articleMark(Arsse::$user->id, ['starred' => false], $c);
+ }
+ $tr->commit();
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ return new EmptyResponse(204);
+ }
+
+ protected function refreshFeed(array $path): ResponseInterface {
+ // NOTE: This is a no-op; we simply check that the feed exists
+ try {
+ Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, (int) $path[1]);
+ } catch (ExceptionInput $e) {
+ return new ErrorResponse("404", 404);
+ }
+ return new EmptyResponse(204);
+ }
+
+ protected function refreshAllFeeds(): ResponseInterface {
+ // NOTE: This is a no-op
+ // It could be implemented, but the need is considered low since we use a dynamic schedule always
+ return new EmptyResponse(204);
+ }
+
+ protected function opmlImport(string $data): ResponseInterface {
+ try {
+ Arsse::$obj->get(OPML::class)->import(Arsse::$user->id, $data);
+ } catch (ImportException $e) {
+ switch ($e->getCode()) {
+ case 10611:
+ return new ErrorResponse("InvalidBodyXML", 400);
+ case 10612:
+ return new ErrorResponse("InvalidBodyOPML", 422);
+ case 10613:
+ return new ErrorResponse("InvalidImportCategory", 422);
+ case 10614:
+ return new ErrorResponse("DuplicateImportCategory", 422);
+ case 10615:
+ return new ErrorResponse("InvalidImportLabel", 422);
+ }
+ } catch (FeedException $e) {
+ return new ErrorResponse(["FailedImportFeed", 'url' => $e->getParams()['url'], 'code' => $e->getCode()], 502);
+ }
+ return new Response(['message' => Arsse::$lang->msg("API.Miniflux.ImportSuccess")]);
+ }
+
+ protected function opmlExport(): ResponseInterface {
+ return new GenericResponse(Arsse::$obj->get(OPML::class)->export(Arsse::$user->id), 200, ['Content-Type' => "application/xml"]);
+ }
+}
diff --git a/lib/REST/NextcloudNews/V1_2.php b/lib/REST/NextcloudNews/V1_2.php
index c7389df8..111cf2f0 100644
--- a/lib/REST/NextcloudNews/V1_2.php
+++ b/lib/REST/NextcloudNews/V1_2.php
@@ -15,8 +15,6 @@ use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Feed\Exception as FeedException;
use JKingWeb\Arsse\Misc\HTTP;
use JKingWeb\Arsse\REST\Exception;
-use JKingWeb\Arsse\REST\Exception404;
-use JKingWeb\Arsse\REST\Exception405;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\JsonResponse as Response;
@@ -24,7 +22,6 @@ use Laminas\Diactoros\Response\EmptyResponse;
class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
public const VERSION = "11.0.5";
- protected const REALM = "Nextcloud News API v1-2";
protected const ACCEPTED_TYPE = "application/json";
protected $dateFormat = "unix";
@@ -79,18 +76,18 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public function dispatch(ServerRequestInterface $req): ResponseInterface {
- // try to authenticate
- if ($req->getAttribute("authenticated", false)) {
- Arsse::$user->id = $req->getAttribute("authenticatedUser");
- } else {
- return new EmptyResponse(401);
- }
// get the request path only; this is assumed to already be normalized
$target = parse_url($req->getRequestTarget())['path'] ?? "";
// handle HTTP OPTIONS requests
if ($req->getMethod() === "OPTIONS") {
return $this->handleHTTPOptions($target);
}
+ // try to authenticate
+ if ($req->getAttribute("authenticated", false)) {
+ Arsse::$user->id = $req->getAttribute("authenticatedUser");
+ } else {
+ return new EmptyResponse(401);
+ }
// normalize the input
$data = (string) $req->getBody();
if ($data) {
@@ -109,15 +106,9 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// merge GET and POST data, and normalize it. POST parameters are preferred over GET parameters
$data = $this->normalizeInput(array_merge($req->getQueryParams(), $data), $this->validInput, "unix");
// check to make sure the requested function is implemented
- try {
- $func = $this->chooseCall($target, $req->getMethod());
- } catch (Exception404 $e) {
- return new EmptyResponse(404);
- } catch (Exception405 $e) {
- return new EmptyResponse(405, ['Allow' => $e->getMessage()]);
- }
- if (!method_exists($this, $func)) {
- return new EmptyResponse(501); // @codeCoverageIgnore
+ $func = $this->chooseCall($target, $req->getMethod());
+ if ($func instanceof ResponseInterface) {
+ return $func;
}
// dispatch
try {
@@ -145,25 +136,37 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
return implode("/", $path);
}
- protected function chooseCall(string $url, string $method): string {
+ protected function normalizeInput(array $data, array $types, string $dateFormat = null, int $mode = 0): array {
+ $out = [];
+ foreach ($types as $key => $type) {
+ if (isset($data[$key])) {
+ $out[$key] = ValueInfo::normalize($data[$key], $type | $mode, $dateFormat);
+ } else {
+ $out[$key] = null;
+ }
+ }
+ return $out;
+ }
+
+ protected function chooseCall(string $url, string $method) {
// // normalize the URL path: change any IDs to 1 for easier comparison
$url = $this->normalizePathIds($url);
// normalize the HTTP method to uppercase
$method = strtoupper($method);
// we now evaluate the supplied URL against every supported path for the selected scope
- // the URL is evaluated as an array so as to avoid decoded escapes turning invalid URLs into valid ones
if (isset($this->paths[$url])) {
// if the path is supported, make sure the method is allowed
if (isset($this->paths[$url][$method])) {
- // if it is allowed, return the object method to run
+ // if it is allowed, return the object method to run, assuming the method exists
+ assert(method_exists($this, $this->paths[$url][$method]), new \Exception("Method is not implemented"));
return $this->paths[$url][$method];
} else {
// otherwise return 405
- throw new Exception405(implode(", ", array_keys($this->paths[$url])));
+ return new EmptyResponse(405, ['Allow' => implode(", ", array_keys($this->paths[$url]))]);
}
} else {
// if the path is not supported, return 404
- throw new Exception404();
+ return new EmptyResponse(404);
}
}
@@ -190,7 +193,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
'added' => "added",
'pinned' => "pinned",
'link' => "source",
- 'faviconLink' => "favicon",
+ 'faviconLink' => "icon_url",
'folderId' => "top_folder",
'unreadCount' => "unread",
'ordering' => "order_type",
@@ -342,7 +345,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(422);
}
// build the context
- $c = new Context;
+ $c = (new Context)->hidden(false);
$c->latestEdition((int) $data['newestItemId']);
$c->folder((int) $url[1]);
// perform the operation
@@ -357,6 +360,9 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// return list of feeds which should be refreshed
protected function feedListStale(array $url, array $data): ResponseInterface {
+ if (!$this->isAdmin()) {
+ return new EmptyResponse(403);
+ }
// list stale feeds which should be checked for updates
$feeds = Arsse::$db->feedListStale();
$out = [];
@@ -369,6 +375,9 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// refresh a feed
protected function feedUpdate(array $url, array $data): ResponseInterface {
+ if (!$this->isAdmin()) {
+ return new EmptyResponse(403);
+ }
try {
Arsse::$db->feedUpdate($data['feedId']);
} catch (ExceptionInput $e) {
@@ -409,7 +418,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
$feed = Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, $id);
$feed = $this->feedTranslate($feed);
$out = ['feeds' => [$feed]];
- $newest = Arsse::$db->editionLatest(Arsse::$user->id, (new Context)->subscription($id));
+ $newest = Arsse::$db->editionLatest(Arsse::$user->id, (new Context)->subscription($id)->hidden(false));
if ($newest) {
$out['newestItemId'] = $newest;
}
@@ -491,7 +500,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(422);
}
// build the context
- $c = new Context;
+ $c = (new Context)->hidden(false);
$c->latestEdition((int) $data['newestItemId']);
$c->subscription((int) $url[1]);
// perform the operation
@@ -507,7 +516,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
// list articles and their properties
protected function articleList(array $url, array $data): ResponseInterface {
// set the context options supplied by the client
- $c = new Context;
+ $c = (new Context)->hidden(false);
// set the batch size
if ($data['batchSize'] > 0) {
$c->limit($data['batchSize']);
@@ -587,7 +596,7 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(422);
}
// build the context
- $c = new Context;
+ $c = (new Context)->hidden(false);
$c->latestEdition((int) $data['newestItemId']);
// perform the operation
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
@@ -664,11 +673,17 @@ class V1_2 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
protected function cleanupBefore(array $url, array $data): ResponseInterface {
+ if (!$this->isAdmin()) {
+ return new EmptyResponse(403);
+ }
Service::cleanupPre();
return new EmptyResponse(204);
}
protected function cleanupAfter(array $url, array $data): ResponseInterface {
+ if (!$this->isAdmin()) {
+ return new EmptyResponse(403);
+ }
Service::cleanupPost();
return new EmptyResponse(204);
}
diff --git a/lib/REST/NextcloudNews/Versions.php b/lib/REST/NextcloudNews/Versions.php
index 8337736b..95ee0bf3 100644
--- a/lib/REST/NextcloudNews/Versions.php
+++ b/lib/REST/NextcloudNews/Versions.php
@@ -16,7 +16,7 @@ class Versions implements \JKingWeb\Arsse\REST\Handler {
}
public function dispatch(ServerRequestInterface $req): ResponseInterface {
- if (!preg_match("<^/?$>", $req->getRequestTarget())) {
+ if (!preg_match("<^/?$>D", $req->getRequestTarget())) {
// if the request path is more than an empty string or a slash, the client is probably trying a version we don't support
return new EmptyResponse(404);
}
diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php
index 2df402a0..74f315a5 100644
--- a/lib/REST/TinyTinyRSS/API.php
+++ b/lib/REST/TinyTinyRSS/API.php
@@ -12,7 +12,7 @@ use JKingWeb\Arsse\Service;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Misc\Date;
-use JKingWeb\Arsse\Misc\ValueInfo;
+use JKingWeb\Arsse\Misc\ValueInfo as V;
use JKingWeb\Arsse\AbstractException;
use JKingWeb\Arsse\ExceptionType;
use JKingWeb\Arsse\Db\ExceptionInput;
@@ -24,8 +24,9 @@ use Laminas\Diactoros\Response\JsonResponse as Response;
use Laminas\Diactoros\Response\EmptyResponse;
class API extends \JKingWeb\Arsse\REST\AbstractHandler {
- public const LEVEL = 14; // emulated API level
+ public const LEVEL = 15; // emulated API level
public const VERSION = "17.4"; // emulated TT-RSS version
+
protected const LABEL_OFFSET = 1024; // offset below zero at which labels begin, counting down
protected const LIMIT_ARTICLES = 200; // maximum number of articles returned by getHeadlines
protected const LIMIT_EXCERPT = 100; // maximum length of excerpts in getHeadlines, counted in grapheme units
@@ -45,42 +46,43 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// valid input
protected const ACCEPTED_TYPES = ["application/json", "text/json"];
protected const VALID_INPUT = [
- 'op' => ValueInfo::T_STRING, // the function ("operation") to perform
- 'sid' => ValueInfo::T_STRING, // session ID
- 'seq' => ValueInfo::T_INT, // request number from client
- 'user' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // user name for `login`
- 'password' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // password for `login` or remote password for `subscribeToFeed`
- 'include_empty' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include empty items in `getFeedTree` and `getCategories`
- 'unread_only' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to exclude items without unread articles in `getCategories` and `getFeeds`
- 'enable_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to NOT show subcategories in `getCategories
- 'include_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include subcategories in `getFeeds` and the articles thereof in `getHeadlines`
- 'caption' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // name for categories, feed, and labels
- 'parent_id' => ValueInfo::T_INT, // parent category for `addCategory` and `moveCategory`
- 'category_id' => ValueInfo::T_INT, // parent category for `subscribeToFeed` and `moveFeed`, and subject for category-modification functions
- 'cat_id' => ValueInfo::T_INT, // parent category for `getFeeds`
- 'label_id' => ValueInfo::T_INT, // label ID in label-related functions
- 'feed_url' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // URL of feed in `subscribeToFeed`
- 'login' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // remote user name in `subscribeToFeed`
- 'feed_id' => ValueInfo::T_INT, // feed, label, or category ID for various functions
- 'is_cat' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether 'feed_id' refers to a category
- 'article_id' => ValueInfo::T_MIXED, // single article ID in `getLabels`; one or more (comma-separated) article IDs in `getArticle`
- 'article_ids' => ValueInfo::T_STRING, // one or more (comma-separated) article IDs in `updateArticle` and `setArticleLabel`
- 'assign' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to assign or clear (false) a label in `setArticleLabel`
- 'limit' => ValueInfo::T_INT, // maximum number of records returned in `getFeeds`, `getHeadlines`, and `getCompactHeadlines`
- 'offset' => ValueInfo::T_INT, // number of records to skip in `getFeeds`, for pagination
- 'skip' => ValueInfo::T_INT, // number of records to skip in `getHeadlines` and `getCompactHeadlines`, for pagination
- 'show_excerpt' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include article excerpts in `getHeadlines`
- 'show_content' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include article content in `getHeadlines`
- 'include_attachments' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include article enclosures in `getHeadlines`
- 'view_mode' => ValueInfo::T_STRING, // various filters for `getHeadlines`
- 'since_id' => ValueInfo::T_INT, // cut-off article ID for `getHeadlines` and `getCompactHeadlines; returns only higher article IDs when specified
- 'order_by' => ValueInfo::T_STRING, // sort order for `getHeadlines`
- 'include_header' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to attach a header to the results of `getHeadlines`
- 'search' => ValueInfo::T_STRING, // search string for `getHeadlines`
- 'field' => ValueInfo::T_INT, // which state to change in `updateArticle`
- 'mode' => ValueInfo::T_INT, // whether to set, clear, or toggle the selected state in `updateArticle`
- 'data' => ValueInfo::T_STRING, // note text in `updateArticle` if setting a note
+ 'op' => V::T_STRING, // the function ("operation") to perform
+ 'sid' => V::T_STRING, // session ID
+ 'seq' => V::T_INT, // request number from client
+ 'user' => V::T_STRING | V::M_STRICT, // user name for `login`
+ 'password' => V::T_STRING | V::M_STRICT, // password for `login` or remote password for `subscribeToFeed`
+ 'include_empty' => V::T_BOOL | V::M_DROP, // whether to include empty items in `getFeedTree` and `getCategories`
+ 'unread_only' => V::T_BOOL | V::M_DROP, // whether to exclude items without unread articles in `getCategories` and `getFeeds`
+ 'enable_nested' => V::T_BOOL | V::M_DROP, // whether to NOT show subcategories in `getCategories
+ 'include_nested' => V::T_BOOL | V::M_DROP, // whether to include subcategories in `getFeeds` and the articles thereof in `getHeadlines`
+ 'caption' => V::T_STRING | V::M_STRICT, // name for categories, feed, and labels
+ 'parent_id' => V::T_INT, // parent category for `addCategory` and `moveCategory`
+ 'category_id' => V::T_INT, // parent category for `subscribeToFeed` and `moveFeed`, and subject for category-modification functions
+ 'cat_id' => V::T_INT, // parent category for `getFeeds`
+ 'label_id' => V::T_INT, // label ID in label-related functions
+ 'feed_url' => V::T_STRING | V::M_STRICT, // URL of feed in `subscribeToFeed`
+ 'login' => V::T_STRING | V::M_STRICT, // remote user name in `subscribeToFeed`
+ 'feed_id' => V::T_INT, // feed, label, or category ID for various functions
+ 'is_cat' => V::T_BOOL | V::M_DROP, // whether 'feed_id' refers to a category
+ 'article_id' => V::T_MIXED, // single article ID in `getLabels`; one or more (comma-separated) article IDs in `getArticle`
+ 'article_ids' => V::T_STRING, // one or more (comma-separated) article IDs in `updateArticle` and `setArticleLabel`
+ 'assign' => V::T_BOOL | V::M_DROP, // whether to assign or clear (false) a label in `setArticleLabel`
+ 'limit' => V::T_INT, // maximum number of records returned in `getFeeds`, `getHeadlines`, and `getCompactHeadlines`
+ 'offset' => V::T_INT, // number of records to skip in `getFeeds`, for pagination
+ 'skip' => V::T_INT, // number of records to skip in `getHeadlines` and `getCompactHeadlines`, for pagination
+ 'show_excerpt' => V::T_BOOL | V::M_DROP, // whether to include article excerpts in `getHeadlines`
+ 'show_content' => V::T_BOOL | V::M_DROP, // whether to include article content in `getHeadlines`
+ 'include_attachments' => V::T_BOOL | V::M_DROP, // whether to include article enclosures in `getHeadlines`
+ 'view_mode' => V::T_STRING, // various filters for `getHeadlines`
+ 'since_id' => V::T_INT, // cut-off article ID for `getHeadlines` and `getCompactHeadlines; returns only higher article IDs when specified
+ 'order_by' => V::T_STRING, // sort order for `getHeadlines`
+ 'include_header' => V::T_BOOL | V::M_DROP, // whether to attach a header to the results of `getHeadlines`
+ 'search' => V::T_STRING, // search string for `getHeadlines`
+ 'field' => V::T_INT, // which state to change in `updateArticle`
+ 'mode' => V::T_MIXED, // whether to set, clear, or toggle the selected state in `updateArticle` (integer), or whether to ignore a certain recent timeframe in `catchupFeed` (string)
+ 'data' => V::T_STRING, // note text in `updateArticle` if setting a note
];
+ protected const VIEW_MODES = ["all_articles", "adaptive", "unread", "marked", "has_note", "published"];
// generic error construct
protected const FATAL_ERR = [
'seq' => null,
@@ -92,7 +94,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public function dispatch(ServerRequestInterface $req): ResponseInterface {
- if (!preg_match("<^(?:/(?:index\.php)?)?$>", $req->getRequestTarget())) {
+ if (!preg_match("<^(?:/(?:index\.php)?)?$>D", $req->getRequestTarget())) {
// reject paths other than the index
return new EmptyResponse(404);
}
@@ -154,6 +156,26 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
}
+ protected function normalizeInput(array $data): array {
+ $out = [];
+ foreach (self::VALID_INPUT as $key => $type) {
+ if (isset($data[$key])) {
+ // TT-RSS accepts "t" and "f" as booleans
+ if ($type === V::T_BOOL | V::M_DROP) {
+ if ($data[$key] === "t") {
+ $data[$key] = true;
+ } elseif ($data[$key] === "f") {
+ $data[$key] = false;
+ }
+ }
+ $out[$key] = V::normalize($data[$key], $type, "unix");
+ } else {
+ $out[$key] = null;
+ }
+ }
+ return $out;
+ }
+
protected function resumeSession(string $id): bool {
// if HTTP authentication was successful and sessions are not enforced, proceed unconditionally
if (isset(Arsse::$user->id) && !Arsse::$conf->userSessionEnforced) {
@@ -234,7 +256,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
public function opGetCounters(array $data): array {
$user = Arsse::$user->id;
$starred = Arsse::$db->articleStarred($user);
- $fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H")));
+ $fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H", $this->now()))->hidden(false));
$countAll = 0;
$countSubs = 0;
$feeds = [];
@@ -254,7 +276,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// prepare data for each subscription; we also add unread counts for their host categories
foreach (Arsse::$db->subscriptionList($user) as $f) {
// add the feed to the list of feeds
- $feeds[] = ['id' => (string) $f['id'], 'updated' => Date::transform($f['updated'], "iso8601", "sql"),'counter' => (int) $f['unread'], 'has_img' => (int) (strlen((string) $f['favicon']) > 0)]; // ID is cast to string for consistency with TTRSS
+ $feeds[] = ['id' => (string) $f['id'], 'updated' => Date::transform($f['updated'], "iso8601", "sql"),'counter' => (int) $f['unread'], 'has_img' => (int) (strlen((string) $f['icon_url']) > 0)]; // ID is cast to string for consistency with TTRSS
// add the feed's unread count to the global unread count
$countAll += $f['unread'];
// add the feed's unread count to its category unread count
@@ -339,7 +361,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
'id' => "FEED:".self::FEED_FRESH,
'bare_id' => self::FEED_FRESH,
'icon' => "images/fresh.png",
- 'unread' => Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H"))),
+ 'unread' => Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H", $this->now()))->hidden(false)),
], $tSpecial),
array_merge([ // Starred articles
'name' => Arsse::$lang->msg("API.TTRSS.Feed.Starred"),
@@ -391,7 +413,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
];
$unread += ($l['articles'] - $l['read']);
}
- // if there are labels, all the label category,
+ // if there are labels, add the "Labels" category,
if ($items) {
$out[] = [
'name' => Arsse::$lang->msg("API.TTRSS.Category.Labels"),
@@ -439,7 +461,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
'name' => $s['title'],
'id' => "FEED:".$s['id'],
'bare_id' => (int) $s['id'],
- 'icon' => $s['favicon'] ? "feed-icons/".$s['id'].".ico" : false,
+ 'icon' => $s['icon_url'] ? "feed-icons/".$s['id'].".ico" : false,
'error' => (string) $s['err_msg'],
'param' => Date::transform($s['updated'], "iso8601", "sql"),
'unread' => 0,
@@ -523,7 +545,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// FIXME: this is pretty inefficient
$f = $map[self::CAT_SPECIAL];
$cats[$f]['unread'] += Arsse::$db->articleStarred($user)['unread']; // starred
- $cats[$f]['unread'] += Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H"))); // fresh
+ $cats[$f]['unread'] += Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H", $this->now()))->hidden(false)); // fresh
if (!$read) {
// if we're only including unread entries, remove any categories with zero unread items (this will by definition also exclude empties)
$count = sizeof($cats);
@@ -587,7 +609,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public function opRemoveCategory(array $data) {
- if (!ValueInfo::id($data['category_id'])) {
+ if (!V::id($data['category_id'])) {
// if the folder is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
@@ -601,7 +623,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public function opMoveCategory(array $data) {
- if (!ValueInfo::id($data['category_id']) || !ValueInfo::id($data['parent_id'], true)) {
+ if (!V::id($data['category_id']) || !V::id($data['parent_id'], true)) {
// if the folder or parent is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
@@ -618,8 +640,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public function opRenameCategory(array $data) {
- $info = ValueInfo::str($data['caption']);
- if (!ValueInfo::id($data['category_id']) || !($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) {
+ $info = V::str($data['caption']);
+ if (!V::id($data['category_id']) || !($info & V::VALID) || ($info & V::EMPTY) || ($info & V::WHITE)) {
// if the folder or its new name are invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
@@ -644,7 +666,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
$offset = $data['offset'] ?? 0;
$nested = $data['include_nested'] ?? false;
// if a special category was selected, nesting does not apply
- if (!ValueInfo::id($cat)) {
+ if (!V::id($cat)) {
$nested = false;
// if the All, Special, or Labels category was selected, pagination also does not apply
if (in_array($cat, [self::CAT_ALL, self::CAT_SPECIAL, self::CAT_LABELS])) {
@@ -675,8 +697,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
if ($cat == self::CAT_ALL || $cat == self::CAT_SPECIAL) {
// gather some statistics
$starred = Arsse::$db->articleStarred($user)['unread'];
- $fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H")));
- $global = Arsse::$db->articleCount($user, (new Context)->unread(true));
+ $fresh = Arsse::$db->articleCount($user, (new Context)->unread(true)->modifiedSince(Date::sub("PT24H", $this->now()))->hidden(false));
+ $global = Arsse::$db->articleCount($user, (new Context)->unread(true)->hidden(false));
$published = 0; // TODO: if the Published feed is implemented, the getFeeds method needs to be adjusted accordingly
$archived = 0; // the archived feed is non-functional in the TT-RSS protocol itself
// build the list; exclude anything with zero unread if requested
@@ -737,7 +759,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// NOTE: the list is a flat one: it includes children, but not other descendents
foreach (Arsse::$db->folderList($user, $cat, false) as $c) {
// get the number of unread for the category and its descendents; those with zero unread are excluded in "unread-only" mode
- $count = Arsse::$db->articleCount($user, (new Context)->unread(true)->folder((int) $c['id']));
+ $count = Arsse::$db->articleCount($user, (new Context)->unread(true)->folder((int) $c['id'])->hidden(false));
if (!$unread || $count) {
$out[] = [
'id' => (int) $c['id'],
@@ -792,7 +814,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
'unread' => (int) $s['unread'],
'cat_id' => (int) $s['folder'],
'feed_url' => $s['url'],
- 'has_icon' => (bool) $s['favicon'],
+ 'has_icon' => (bool) $s['icon_url'],
'last_updated' => (int) Date::transform($s['updated'], "unix", "sql"),
'order_id' => $order,
];
@@ -818,7 +840,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public function opSubscribeToFeed(array $data): array {
- if (!$data['feed_url'] || !ValueInfo::id($data['category_id'], true)) {
+ if (!$data['feed_url'] || !V::id($data['category_id'], true)) {
// if the feed URL or the category ID is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
@@ -885,7 +907,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public function opMoveFeed(array $data) {
- if (!ValueInfo::id($data['feed_id']) || !isset($data['category_id']) || !ValueInfo::id($data['category_id'], true)) {
+ if (!V::id($data['feed_id']) || !isset($data['category_id']) || !V::id($data['category_id'], true)) {
// if the feed or folder is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
@@ -902,8 +924,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public function opRenameFeed(array $data) {
- $info = ValueInfo::str($data['caption']);
- if (!ValueInfo::id($data['feed_id']) || !($info & ValueInfo::VALID) || ($info & ValueInfo::EMPTY) || ($info & ValueInfo::WHITE)) {
+ $info = V::str($data['caption']);
+ if (!V::id($data['feed_id']) || !($info & V::VALID) || ($info & V::EMPTY) || ($info & V::WHITE)) {
// if the feed ID or name is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
@@ -920,12 +942,12 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
public function opUpdateFeed(array $data): array {
- if (!isset($data['feed_id']) || !ValueInfo::id($data['feed_id'])) {
+ if (!isset($data['feed_id']) || !V::id($data['feed_id'])) {
// if the feed is invalid, throw an error
throw new Exception("INCORRECT_USAGE");
}
try {
- Arsse::$db->feedUpdate(Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, $data['feed_id'])['feed']);
+ Arsse::$db->feedUpdate((int) Arsse::$db->subscriptionPropertiesGet(Arsse::$user->id, $data['feed_id'])['feed']);
} catch (ExceptionInput $e) {
throw new Exception("FEED_NOT_FOUND");
}
@@ -933,7 +955,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
protected function labelIn($id, bool $throw = true): int {
- if (!(ValueInfo::int($id) & ValueInfo::NEG) || $id > (-1 - self::LABEL_OFFSET)) {
+ if (!(V::int($id) & V::NEG) || $id > (-1 - self::LABEL_OFFSET)) {
if ($throw) {
throw new Exception("INCORRECT_USAGE");
} else {
@@ -949,9 +971,9 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
public function opGetLabels(array $data): array {
// this function doesn't complain about invalid article IDs
- $article = ValueInfo::id($data['article_id']) ? $data['article_id'] : 0;
+ $article = V::id($data['article_id']) ? $data['article_id'] : 0;
try {
- $list = $article ? Arsse::$db->articleLabelsGet(Arsse::$user->id, $article) : [];
+ $list = $article ? Arsse::$db->articleLabelsGet(Arsse::$user->id, (int) $article) : [];
} catch (ExceptionInput $e) {
$list = [];
}
@@ -1035,9 +1057,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
public function opCatchUpFeed(array $data): array {
$id = $data['feed_id'] ?? self::FEED_ARCHIVED;
$cat = $data['is_cat'] ?? false;
+ $mode = $data['mode'] ?? "all";
$out = ['status' => "OK"];
// first prepare the context; unsupported contexts simply return early
- $c = new Context;
+ $c = (new Context)->hidden(false);
if ($cat) { // categories
switch ($id) {
case self::CAT_SPECIAL:
@@ -1073,7 +1096,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// TODO: if the Published feed is implemented, the catchup function needs to be modified accordingly
return $out;
case self::FEED_FRESH:
- $c->modifiedSince(Date::sub("PT24H"));
+ $c->modifiedSince(Date::sub("PT24H", $this->now()));
break;
case self::FEED_ALL:
// no context needed here
@@ -1087,6 +1110,16 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
}
}
+ switch ($mode) {
+ case "2week":
+ $c->notModifiedSince(Date::sub("P2W", $this->now()));
+ break;
+ case "1week":
+ $c->notModifiedSince(Date::sub("P1W", $this->now()));
+ break;
+ case "1day":
+ $c->notModifiedSince(Date::sub("PT24H", $this->now()));
+ }
// perform the marking
try {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
@@ -1099,7 +1132,8 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
public function opUpdateArticle(array $data): array {
// normalize input
- $articles = array_filter(ValueInfo::normalize(explode(",", (string) $data['article_ids']), ValueInfo::T_INT | ValueInfo::M_ARRAY), [ValueInfo::class, "id"]);
+ $articles = array_filter(V::normalize(explode(",", (string) $data['article_ids']), V::T_INT | V::M_ARRAY), [V::class, "id"]);
+ $data['mode'] = V::normalize($data['mode'], V::T_INT);
if (!$articles) {
// if there are no valid articles this is an error
throw new Exception("INCORRECT_USAGE");
@@ -1171,7 +1205,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
public function opGetArticle(array $data): array {
// normalize input
- $articles = array_filter(ValueInfo::normalize(explode(",", (string) $data['article_id']), ValueInfo::T_INT | ValueInfo::M_ARRAY), [ValueInfo::class, "id"]);
+ $articles = array_filter(V::normalize(explode(",", (string) $data['article_id']), V::T_INT | V::M_ARRAY), [V::class, "id"]);
if (!$articles) {
// if there are no valid articles this is an error
throw new Exception("INCORRECT_USAGE");
@@ -1188,6 +1222,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
"id",
"guid",
"title",
+ "author",
"url",
"unread",
"starred",
@@ -1296,10 +1331,14 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
"subscription",
"subscription_title",
"note",
- ($data['show_content'] || $data['show_excerpt']) ? "content" : "",
- ($data['include_attachments']) ? "media_url": "",
- ($data['include_attachments']) ? "media_type": "",
];
+ if ($data['show_content'] || $data['show_excerpt']) {
+ $columns[] = "content";
+ }
+ if ($data['include_attachments']) {
+ $columns[] = "media_url";
+ $columns[] = "media_type";
+ }
foreach ($this->fetchArticles($data, $columns) as $article) {
$row = [
'id' => (int) $article['id'],
@@ -1318,7 +1357,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
'score' => 0, // score is not implemented as it is not modifiable from the TTRSS API
'note' => strlen((string) $article['note']) ? $article['note'] : null,
'lang' => "", // FIXME: picoFeed should be able to retrieve this information
- 'tags' => Arsse::$db->articleCategoriesGet(Arsse::$user->id, $article['id']),
+ 'tags' => Arsse::$db->articleCategoriesGet(Arsse::$user->id, (int) $article['id']),
'comments_count' => 0,
'comments_link' => "",
'always_display_attachments' => false,
@@ -1387,9 +1426,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
$id = $data['feed_id'];
$cat = $data['is_cat'] ?? false;
$shallow = !($data['include_nested'] ?? false);
- $viewMode = in_array($data['view_mode'], ["all_articles", "adaptive", "unread", "marked", "has_note", "published"]) ? $data['view_mode'] : "all_articles";
+ $viewMode = in_array($data['view_mode'], self::VIEW_MODES) ? $data['view_mode'] : "all_articles";
+ assert(in_array($viewMode, self::VIEW_MODES), new \JKingWeb\Arsse\Exception("constantUnknown", $viewMode));
// prepare the context; unsupported, invalid, or inherently empty contexts return synthetic empty result sets
- $c = new Context;
+ $c = (new Context)->hidden(false);
$tr = Arsse::$db->begin();
// start with the feed or category ID
if ($cat) { // categories
@@ -1433,13 +1473,13 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// TODO: if the Published feed is implemented, the headline function needs to be modified accordingly
return new ResultEmpty;
case self::FEED_FRESH:
- $c->modifiedSince(Date::sub("PT24H"))->unread(true);
+ $c->modifiedSince(Date::sub("PT24H", $this->now()))->unread(true);
break;
case self::FEED_ALL:
// no context needed here
break;
case self::FEED_READ:
- $c->markedSince(Date::sub("PT24H"))->unread(false); // FIXME: this selects any recently touched (read, starred, annotated) article which is read, not necessarily a recently read one
+ $c->markedSince(Date::sub("PT24H", $this->now()))->unread(false); // FIXME: this selects any recently touched (read, starred, annotated) article which is read, not necessarily a recently read one
break;
default:
// any actual feed
@@ -1477,8 +1517,6 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// not implemented
// TODO: if the Published feed is implemented, the headline function needs to be modified accordingly
return new ResultEmpty;
- default:
- throw new \JKingWeb\Arsse\Exception("constantUnknown", $viewMode); // @codeCoverageIgnore
}
// handle the search string, if any
if (isset($data['search'])) {
diff --git a/lib/REST/TinyTinyRSS/Icon.php b/lib/REST/TinyTinyRSS/Icon.php
index c5c9030e..dd718bcb 100644
--- a/lib/REST/TinyTinyRSS/Icon.php
+++ b/lib/REST/TinyTinyRSS/Icon.php
@@ -7,6 +7,7 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\REST\TinyTinyRSS;
use JKingWeb\Arsse\Arsse;
+use JKingWeb\Arsse\Db\ExceptionInput;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\EmptyResponse as Response;
@@ -26,17 +27,19 @@ class Icon extends \JKingWeb\Arsse\REST\AbstractHandler {
if ($req->getMethod() !== "GET") {
// only GET requests are allowed
return new Response(405, ['Allow' => "GET"]);
- } elseif (!preg_match("<^(\d+)\.ico$>", $req->getRequestTarget(), $match) || !((int) $match[1])) {
+ } elseif (!preg_match("<^(\d+)\.ico$>D", $req->getRequestTarget(), $match) || !((int) $match[1])) {
return new Response(404);
}
- $url = Arsse::$db->subscriptionFavicon((int) $match[1], Arsse::$user->id ?? null);
- if ($url) {
- // strip out anything after literal line-end characters; this is to mitigate a potential header (e.g. cookie) injection from the URL
+ try {
+ $url = Arsse::$db->subscriptionIcon(Arsse::$user->id ?? null, (int) $match[1], false)['url'] ?? null;
+ if (!$url) {
+ return new Response(404);
+ }
if (($pos = strpos($url, "\r")) !== false || ($pos = strpos($url, "\n")) !== false) {
$url = substr($url, 0, $pos);
}
return new Response(301, ['Location' => $url]);
- } else {
+ } catch (ExceptionInput $e) {
return new Response(404);
}
}
diff --git a/lib/REST/Exception404.php b/lib/Rule/Exception.php
similarity index 61%
rename from lib/REST/Exception404.php
rename to lib/Rule/Exception.php
index 8bee1922..1239e378 100644
--- a/lib/REST/Exception404.php
+++ b/lib/Rule/Exception.php
@@ -4,7 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
-namespace JKingWeb\Arsse\REST;
+namespace JKingWeb\Arsse\Rule;
-class Exception404 extends \Exception {
+class Exception extends \JKingWeb\Arsse\AbstractException {
}
diff --git a/lib/Rule/Rule.php b/lib/Rule/Rule.php
new file mode 100644
index 00000000..c8d41898
--- /dev/null
+++ b/lib/Rule/Rule.php
@@ -0,0 +1,85 @@
+", $pattern, $m, \PREG_OFFSET_CAPTURE)) {
+ // where necessary escape our chosen delimiter (backtick) in reverse order
+ foreach (array_reverse($m[0]) as [,$pos]) {
+ // count the number of backslashes preceding the delimiter character
+ $count = 0;
+ $p = $pos;
+ while ($p-- && $pattern[$p] === "\\" && ++$count);
+ // if the number is even (including zero), add a backslash
+ if ($count % 2 === 0) {
+ $pattern = substr($pattern, 0, $pos)."\\".substr($pattern, $pos);
+ }
+ }
+ }
+ // add the delimiters and test the pattern
+ $pattern = "`$pattern`u";
+ if (@preg_match($pattern, "") === false) {
+ throw new Exception("invalidPattern");
+ }
+ return $pattern;
+ }
+
+ public static function validate(string $pattern): bool {
+ try {
+ static::prep($pattern);
+ } catch (Exception $e) {
+ return false;
+ }
+ return true;
+ }
+
+ /** applies keep and block rules against the title and categories of an article
+ *
+ * Returns true if the article is to be kept, and false if it is to be suppressed
+ */
+ public static function apply(string $keepPattern, string $blockPattern, string $title, array $categories = []): bool {
+ // ensure input is valid
+ assert(!strlen($keepPattern) || @preg_match($keepPattern, "") !== false, new \Exception("Keep pattern is invalid"));
+ assert(!strlen($blockPattern) || @preg_match($blockPattern, "") !== false, new \Exception("Block pattern is invalid"));
+ assert(sizeof(array_filter($categories, function($v) {
+ return !is_string($v);
+ })) === 0, new \Exception("All categories must be strings"));
+ // if neither rule is processed we should keep
+ $keep = true;
+ // merge and clean the data to match
+ $data = array_map(function($str) {
+ return preg_replace('/\s+/', " ", $str);
+ }, array_merge([$title], $categories));
+ // process the keep rule if it exists
+ if (strlen($keepPattern)) {
+ // if a keep rule is specified the default state is now not to keep
+ $keep = false;
+ foreach ($data as $str) {
+ if (preg_match($keepPattern, $str)) {
+ // keep if the keep-rule matches one of the strings
+ $keep = true;
+ break;
+ }
+ }
+ }
+ // process the block rule if the keep rule was matched
+ if ($keep && strlen($blockPattern)) {
+ foreach ($data as $str) {
+ if (preg_match($blockPattern, $str)) {
+ // do not keep if the block-rule matches one of the strings
+ $keep = false;
+ break;
+ }
+ }
+ }
+ return $keep;
+ }
+}
diff --git a/lib/Service.php b/lib/Service.php
index 597421fd..7eb31779 100644
--- a/lib/Service.php
+++ b/lib/Service.php
@@ -16,6 +16,8 @@ class Service {
/** @var Service\Driver */
protected $drv;
+ protected $loop = false;
+ protected $reload = false;
public function __construct() {
$driver = Arsse::$conf->serviceDriver;
@@ -23,6 +25,8 @@ class Service {
}
public function watch(bool $loop = true): \DateTimeInterface {
+ $this->loop = $loop;
+ $this->signalInit();
$t = new \DateTime();
do {
$this->checkIn();
@@ -37,16 +41,30 @@ class Service {
static::cleanupPost();
$t->add(Arsse::$conf->serviceFrequency);
// @codeCoverageIgnoreStart
- if ($loop) {
+ if ($this->loop) {
do {
- @time_sleep_until($t->getTimestamp());
- } while ($t->getTimestamp() > time());
+ sleep((int) max(0, $t->getTimestamp() - time()));
+ if (function_exists("pcntl_signal_dispatch")) {
+ pcntl_signal_dispatch();
+ if ($this->reload) {
+ $this->reload();
+ fwrite(\STDERR, Arsse::$lang->msg("Service.Reload").\PHP_EOL);
+ }
+ }
+ } while ($this->loop && $t->getTimestamp() > time());
}
// @codeCoverageIgnoreEnd
- } while ($loop);
+ } while ($this->loop);
return $t;
}
+ public function reload(): void {
+ $this->reload = false;
+ Arsse::$user = Arsse::$db = Arsse::$conf = Arsse::$lang = Arsse::$obj = $this->drv = null;
+ Arsse::bootstrap();
+ $this->__construct();
+ }
+
public function checkIn(): bool {
return Arsse::$db->metaSet("service_last_checkin", time(), "datetime");
}
@@ -72,6 +90,8 @@ class Service {
public static function cleanupPre(): bool {
// mark unsubscribed feeds as orphaned and delete orphaned feeds that are beyond their retention period
Arsse::$db->feedCleanup();
+ // do the same for icons
+ Arsse::$db->iconCleanup();
// delete expired log-in sessions
Arsse::$db->sessionCleanup();
return true;
@@ -86,4 +106,29 @@ class Service {
}
return true;
}
+
+ protected function signalInit(): void {
+ if (function_exists("pcntl_async_signals") && function_exists("pcntl_signal")) {
+ // receive asynchronous signals if supported
+ pcntl_async_signals(true);
+ foreach ([\SIGABRT, \SIGINT, \SIGTERM] as $sig) {
+ pcntl_signal($sig, [$this, "sigTerm"]);
+ }
+ pcntl_signal(\SIGHUP, [$this, "sigHup"]);
+ }
+ }
+
+ /** Changes the condition for the service loop upon receiving a termination signal
+ *
+ * @codeCoverageIgnore */
+ protected function sigTerm(int $signo): void {
+ $this->loop = false;
+ }
+
+ /** Changes the condition for the service loop upon receiving a hangup signal
+ *
+ * @codeCoverageIgnore */
+ protected function sigHup(int $signo): void {
+ $this->reload = true;
+ }
}
diff --git a/lib/Service/Daemon.php b/lib/Service/Daemon.php
new file mode 100644
index 00000000..c77eecfe
--- /dev/null
+++ b/lib/Service/Daemon.php
@@ -0,0 +1,226 @@
+checkPID($pidfile, false);
+ // We will follow systemd's recommended daemonizing process as much as possible:
+ # Close all open file descriptors except standard input, output, and error (i.e. the first three file descriptors 0, 1, 2). This ensures that no accidentally passed file descriptor stays around in the daemon process. On Linux, this is best implemented by iterating through /proc/self/fd, with a fallback of iterating from file descriptor 3 to the value returned by getrlimit() for RLIMIT_NOFILE.
+ // We should have no open file descriptors at this time. Even if we did, I'm not certain how they should be closed from PHP
+ # Reset all signal handlers to their default. This is best done by iterating through the available signals up to the limit of _NSIG and resetting them to SIG_DFL.
+ // We have not yet set any signal handlers, so this should be fine
+ # Reset the signal mask using sigprocmask().
+ pcntl_sigprocmask(\SIG_SETMASK, []);
+ # Sanitize the environment block, removing or resetting environment variables that might negatively impact daemon runtime.
+ //Not necessary; we don't use the environment
+ # Call fork(), to create a background process.
+ $pipe = stream_socket_pair(\STREAM_PF_UNIX, \STREAM_SOCK_STREAM, \STREAM_IPPROTO_IP);
+ switch (@pcntl_fork()) {
+ case -1:
+ // Unable to fork
+ throw new Exception("forkFailed", ['instance' => 1]);
+ case 0:
+ fclose($pipe[0]);
+ # In the child, call setsid() to detach from any terminal and create an independent session.
+ try {
+ if (@posix_setsid() === -1) {
+ throw new Exception("forkFailed", ['instance' => 1]);
+ }
+ # In the child, call fork() again, to ensure that the daemon can never re-acquire a terminal again. (This relevant if the program — and all its dependencies — does not carefully specify `O_NOCTTY` on each and every single `open()` call that might potentially open a TTY device node.)
+ switch (@pcntl_fork()) {
+ case -1:
+ // Unable to fork
+ throw new Exception("forkFailed", ['instance' => 2]);
+ case 0:
+ // We do some things out of order because as far as I know there's no way to reconnect stdin, stdout, and stderr without closing the channel to the parent first
+ # In the daemon process, write the daemon PID (as returned by getpid()) to a PID file, for example /run/foobar.pid (for a hypothetical daemon "foobar") to ensure that the daemon cannot be started more than once. This must be implemented in race-free fashion so that the PID file is only updated when it is verified at the same time that the PID previously stored in the PID file no longer exists or belongs to a foreign process.
+ $this->writePID($pidfile);
+ # In the daemon process, drop privileges, if possible and applicable.
+ // already done
+ # From the daemon process, notify the original process started that initialization is complete. This can be implemented via an unnamed pipe or similar communication channel that is created before the first fork() and hence available in both the original and the daemon process.
+ fwrite($pipe[1], (string) posix_getpid());
+ fclose($pipe[1]);
+ // now everything else is done in order, but beyond this point any errors cannot be reported back to the original process
+ # In the daemon process, connect /dev/null to standard input, output, and error.
+ fclose(STDIN);
+ fclose(STDOUT);
+ fclose(STDERR);
+ global $STDIN, $STDOUT, $STDERR;
+ $STDIN = fopen("/dev/null", "r");
+ $STDOUT = fopen("/dev/null", "w");
+ $STDERR = fopen("/dev/null", "w");
+ # In the daemon process, reset the umask to 0, so that the file modes passed to open(), mkdir() and suchlike directly control the access mode of the created files and directories.
+ umask(0);
+ # In the daemon process, change the current directory to the root directory (/), in order to avoid that the daemon involuntarily blocks mount points from being unmounted.
+ chdir("/");
+ return;
+ default:
+ # Call exit() in the first child, so that only the second child (the actual daemon process) stays around. This ensures that the daemon process is re-parented to init/PID 1, as all daemons should be.
+ exit;
+ }
+ } catch (AbstractException $e) {
+ // transmit the exception back to the original process, which will re-create the exception if necessary
+ @fwrite($pipe[1], json_encode([get_class($e), $e->getSymbol(), $e->getParams()]));
+ exit;
+ }
+ default:
+ fclose($pipe[1]);
+ $result = json_decode(fread($pipe[0], 100), true);
+ if (is_array($result)) {
+ [$class, $symbol, $params] = $result;
+ throw new $class($symbol, $params);
+ }
+ fclose($pipe[0]);
+ # Call exit() in the original process. The process that invoked the daemon must be able to rely on that this exit() happens after initialization is complete and all external communication channels are established and accessible.
+ exit;
+ }
+ }
+
+ public function checkPID(string $pidfile): void {
+ if (file_exists($pidfile)) {
+ $pid = @file_get_contents($pidfile);
+ if ($pid !== false) {
+ if (preg_match(static::PID_PATTERN, $pid)) {
+ if (strlen($pid) && $this->processExists((int) $pid)) {
+ throw new Exception("pidDuplicate", ['pid' => $pid]);
+ }
+ } else {
+ throw new Exception("pidCorrupt", ['pidfile' => $pidfile]);
+ }
+ } else {
+ throw new Exception("pidInaccessible", ['pidfile' => $pidfile]);
+ }
+ }
+ }
+
+ public function writePID(string $pidfile): void {
+ if ($f = @fopen($pidfile, "c+")) {
+ if (@flock($f, \LOCK_EX | \LOCK_NB)) {
+ try {
+ // confirm that some other process didn't get in before us
+ $pid = fread($f, 80);
+ if (preg_match(static::PID_PATTERN, (string) $pid)) {
+ if (strlen($pid) && $this->processExists((int) $pid)) {
+ throw new Exception("pidDuplicate", ['pid' => $pid]);
+ }
+ } else {
+ throw new Exception("pidCorrupt", ['pidfile' => $pidfile]);
+ }
+ // write the PID to the pidfile
+ rewind($f);
+ if (!ftruncate($f, 0) || !fwrite($f, (string) posix_getpid())) {
+ throw new Exception("pidInaccessible", ['pidfile' => $pidfile]);
+ }
+ } finally {
+ flock($f, \LOCK_UN);
+ fclose($f);
+ }
+ } else {
+ throw new Exception("pidLocked", ['pidfile' => $pidfile]);
+ }
+ } else {
+ throw new Exception("pidInaccessible", ['pidfile' => $pidfile]);
+ }
+ }
+
+ /** Wrapper around posix_kill (with signal 0) to facilitation testing
+ *
+ * @codeCoverageIgnore
+ */
+ protected function processExists(int $pid): bool {
+ return @posix_kill($pid, 0);
+ }
+
+ /** Resolves the PID file path and ensures the file or parent directory is writable */
+ public function checkPIDFilePath(string $pidfile): string {
+ $dir = dirname($pidfile);
+ $file = basename($pidfile);
+ $base = $this->resolveRelativePath($dir);
+ if (!strlen($file)) {
+ throw new Exception("pidNotFile", ['pidfile' => $dir]);
+ } elseif ($base) {
+ $out = "$base/$file";
+ if (file_exists($out)) {
+ if (!is_file($out)) {
+ throw new Exception("pidNotFile", ['pidfile' => $out]);
+ } elseif (!is_readable($out) && !is_writable($out)) {
+ throw new Exception("pidUnusable", ['pidfile' => $out]);
+ } elseif (!is_readable($out)) {
+ throw new Exception("pidUnreadable", ['pidfile' => $out]);
+ } elseif (!is_writable($out)) {
+ throw new Exception("pidUnwritable", ['pidfile' => $out]);
+ }
+ } elseif (!is_dir($base)) {
+ throw new Exception("pidDirMissing", ['piddir' => $dir]);
+ } elseif (!is_writable($base)) {
+ throw new Exception("pidUncreatable", ['pidfile' => $out]);
+ }
+ } else {
+ throw new Exception("pidDirUnresolvable", ['piddir' => $dir]);
+ }
+ return $out;
+ }
+
+ /** Resolves paths with relative components
+ *
+ * This method has fewer filesystem access requirements than the native
+ * realpath() function. The current working directory most be resolvable
+ * for a relative path, but for absolute paths with relativelu components
+ * the filesystem is not involved at all.
+ *
+ * Consequently symbolic links are not resolved.
+ *
+ * @return string|false
+ */
+ public function resolveRelativePath(string $path) {
+ if ($path[0] !== "/") {
+ $cwd = $this->cwd();
+ if ($cwd === false) {
+ return false;
+ }
+ $path = explode("/", substr($cwd, 1)."/".$path);
+ } else {
+ $path = explode("/", substr($path, 1));
+ }
+ $out = [];
+ foreach ($path as $p) {
+ if ($p === "..") {
+ array_pop($out);
+ } elseif ($p === ".") {
+ continue;
+ } else {
+ $out[] = $p;
+ }
+ }
+ return "/".implode("/", $out);
+ }
+
+ /** Wrapper around posix_getcwd to facilitate testing
+ *
+ * @return string|false
+ * @codeCoverageIgnore
+ */
+ protected function cwd() {
+ return posix_getcwd();
+ }
+}
diff --git a/lib/REST/Exception405.php b/lib/Service/Exception.php
similarity index 61%
rename from lib/REST/Exception405.php
rename to lib/Service/Exception.php
index 842ccdbb..65e8c650 100644
--- a/lib/REST/Exception405.php
+++ b/lib/Service/Exception.php
@@ -4,7 +4,7 @@
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
-namespace JKingWeb\Arsse\REST;
+namespace JKingWeb\Arsse\Service;
-class Exception405 extends \Exception {
+class Exception extends \JKingWeb\Arsse\AbstractException {
}
diff --git a/lib/User.php b/lib/User.php
index f5299914..4bf8e36b 100644
--- a/lib/User.php
+++ b/lib/User.php
@@ -6,12 +6,28 @@
declare(strict_types=1);
namespace JKingWeb\Arsse;
+use JKingWeb\Arsse\Misc\ValueInfo as V;
+use JKingWeb\Arsse\User\ExceptionConflict as Conflict;
use PasswordGenerator\Generator as PassGen;
class User {
public const DRIVER_NAMES = [
'internal' => \JKingWeb\Arsse\User\Internal\Driver::class,
];
+ public const PROPERTIES = [
+ 'admin' => V::T_BOOL,
+ 'lang' => V::T_STRING,
+ 'tz' => V::T_STRING,
+ 'root_folder_name' => V::T_STRING,
+ 'sort_asc' => V::T_BOOL,
+ 'theme' => V::T_STRING,
+ 'page_size' => V::T_INT, // greater than zero
+ 'shortcuts' => V::T_BOOL,
+ 'gestures' => V::T_BOOL,
+ 'reading_time' => V::T_BOOL,
+ 'stylesheet' => V::T_STRING,
+ ];
+ public const PROPERTIES_LARGE = ["stylesheet"];
public $id = null;
@@ -26,9 +42,19 @@ class User {
return (string) $this->id;
}
- public function authorize(string $affectedUser, string $action): bool {
- // at one time there was a complicated authorization system; it exists vestigially to support a later revival if desired
- return $this->u->authorize($affectedUser, $action);
+ public function begin(): Db\Transaction {
+ /* TODO: A proper implementation of this would return a meta-transaction
+ object which would contain both a user-manager transaction (when
+ applicable) and a database transaction, and commit or roll back both
+ as the situation calls.
+
+ In theory, an external user driver would probably have to implement its
+ own approximation of atomic transactions and rollback. In practice the
+ only driver is the internal one, which is always backed by an ACID
+ database; the added complexity is thus being deferred until such time
+ as it is actually needed for a concrete implementation.
+ */
+ return Arsse::$db->begin();
}
public function auth(string $user, string $password): bool {
@@ -49,64 +75,90 @@ class User {
}
public function list(): array {
- $func = "userList";
- if (!$this->authorize("", $func)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => ""]);
- }
return $this->u->userList();
}
- public function exists(string $user): bool {
- $func = "userExists";
- if (!$this->authorize($user, $func)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]);
- }
- return $this->u->userExists($user);
+ public function lookup(int $num): string {
+ // the user number is always stored in the internal database, so the user driver is not called here
+ return Arsse::$db->userLookup($num);
}
- public function add($user, $password = null): string {
- $func = "userAdd";
- if (!$this->authorize($user, $func)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]);
+ public function add(string $user, ?string $password = null): string {
+ // ensure the user name does not contain any U+003A COLON or control characters, as
+ // this is incompatible with HTTP Basic authentication
+ if (preg_match("/[\x{00}-\x{1F}\x{7F}:]/", $user, $m)) {
+ $c = ord($m[0]);
+ throw new User\ExceptionInput("invalidUsername", "U+".str_pad((string) $c, 4, "0", \STR_PAD_LEFT)." ".\IntlChar::charName($c, \IntlChar::EXTENDED_CHAR_NAME));
}
- return $this->u->userAdd($user, $password) ?? $this->u->userAdd($user, $this->generatePassword());
+ try {
+ $out = $this->u->userAdd($user, $password) ?? $this->u->userAdd($user, $this->generatePassword());
+ } catch (Conflict $e) {
+ if (!Arsse::$db->userExists($user)) {
+ Arsse::$db->userAdd($user, null);
+ }
+ throw $e;
+ }
+ // synchronize the internal database
+ if (!Arsse::$db->userExists($user)) {
+ Arsse::$db->userAdd($user, $out);
+ }
+ return $out;
+ }
+
+ public function rename(string $user, string $newName): bool {
+ // ensure the new user name does not contain any U+003A COLON or
+ // control characters, as this is incompatible with HTTP Basic authentication
+ if (preg_match("/[\x{00}-\x{1F}\x{7F}:]/", $newName, $m)) {
+ $c = ord($m[0]);
+ throw new User\ExceptionInput("invalidUsername", "U+".str_pad((string) $c, 4, "0", \STR_PAD_LEFT)." ".\IntlChar::charName($c, \IntlChar::EXTENDED_CHAR_NAME));
+ }
+ if ($this->u->userRename($user, $newName)) {
+ $tr = Arsse::$db->begin();
+ if (!Arsse::$db->userExists($user)) {
+ Arsse::$db->userAdd($newName, null);
+ } else {
+ Arsse::$db->userRename($user, $newName);
+ // invalidate any sessions and Fever passwords
+ Arsse::$db->sessionDestroy($newName);
+ Arsse::$db->tokenRevoke($newName, "fever.login");
+ }
+ $tr->commit();
+ return true;
+ }
+ return false;
}
public function remove(string $user): bool {
- $func = "userRemove";
- if (!$this->authorize($user, $func)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]);
- }
try {
- return $this->u->userRemove($user);
- } finally { // @codeCoverageIgnore
+ $out = $this->u->userRemove($user);
+ } catch (Conflict $e) {
if (Arsse::$db->userExists($user)) {
- // if the user was removed and we (still) have it in the internal database, remove it there
Arsse::$db->userRemove($user);
}
+ throw $e;
}
+ if (Arsse::$db->userExists($user)) {
+ // if the user was removed and we (still) have it in the internal database, remove it there
+ Arsse::$db->userRemove($user);
+ }
+ return $out;
}
- public function passwordSet(string $user, string $newPassword = null, $oldPassword = null): string {
- $func = "userPasswordSet";
- if (!$this->authorize($user, $func)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]);
- }
+ public function passwordSet(string $user, ?string $newPassword, $oldPassword = null): string {
$out = $this->u->userPasswordSet($user, $newPassword, $oldPassword) ?? $this->u->userPasswordSet($user, $this->generatePassword(), $oldPassword);
if (Arsse::$db->userExists($user)) {
// if the password change was successful and the user exists, set the internal password to the same value
Arsse::$db->userPasswordSet($user, $out);
// also invalidate any current sessions for the user
Arsse::$db->sessionDestroy($user);
+ } else {
+ // if the user does not exist, add it with the new password
+ Arsse::$db->userAdd($user, $out);
}
return $out;
}
public function passwordUnset(string $user, $oldPassword = null): bool {
- $func = "userPasswordUnset";
- if (!$this->authorize($user, $func)) {
- throw new User\ExceptionAuthz("notAuthorized", ["action" => $func, "user" => $user]);
- }
$out = $this->u->userPasswordUnset($user, $oldPassword);
if (Arsse::$db->userExists($user)) {
// if the password change was successful and the user exists, set the internal password to the same value
@@ -120,4 +172,53 @@ class User {
public function generatePassword(): string {
return (new PassGen)->length(Arsse::$conf->userTempPasswordLength)->get();
}
+
+ public function propertiesGet(string $user, bool $includeLarge = true): array {
+ $extra = $this->u->userPropertiesGet($user, $includeLarge);
+ // synchronize the internal database
+ if (!Arsse::$db->userExists($user)) {
+ Arsse::$db->userAdd($user, null);
+ Arsse::$db->userPropertiesSet($user, $extra);
+ }
+ // retrieve from the database to get at least the user number, and anything else the driver does not provide
+ $meta = Arsse::$db->userPropertiesGet($user, $includeLarge);
+ // combine all the data
+ $out = ['num' => $meta['num']];
+ foreach (self::PROPERTIES as $k => $t) {
+ if (array_key_exists($k, $extra)) {
+ $v = $extra[$k];
+ } elseif (array_key_exists($k, $meta)) {
+ $v = $meta[$k];
+ } else {
+ $v = null;
+ }
+ $out[$k] = V::normalize($v, $t | V::M_NULL);
+ }
+ return $out;
+ }
+
+ public function propertiesSet(string $user, array $data): array {
+ $in = [];
+ foreach (self::PROPERTIES as $k => $t) {
+ if (array_key_exists($k, $data)) {
+ try {
+ $in[$k] = V::normalize($data[$k], $t | V::M_NULL | V::M_STRICT);
+ } catch (\JKingWeb\Arsse\ExceptionType $e) {
+ throw new User\ExceptionInput("invalidValue", ['field' => $k, 'type' => $t], $e);
+ }
+ }
+ }
+ if (isset($in['tz']) && !@timezone_open($in['tz'])) {
+ throw new User\ExceptionInput("invalidTimezone", ['field' => "tz", 'value' => $in['tz']]);
+ } elseif (isset($in['page_size']) && $in['page_size'] < 1) {
+ throw new User\ExceptionInput("invalidNonZeroInteger", ['field' => "page_size"]);
+ }
+ $out = $this->u->userPropertiesSet($user, $in);
+ // synchronize the internal database
+ if (!Arsse::$db->userExists($user)) {
+ Arsse::$db->userAdd($user, null);
+ }
+ Arsse::$db->userPropertiesSet($user, $out);
+ return $out;
+ }
}
diff --git a/lib/User/Driver.php b/lib/User/Driver.php
index 8faaec71..fcf2010f 100644
--- a/lib/User/Driver.php
+++ b/lib/User/Driver.php
@@ -7,28 +7,79 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\User;
interface Driver {
- public const FUNC_NOT_IMPLEMENTED = 0;
- public const FUNC_INTERNAL = 1;
- public const FUNC_EXTERNAL = 2;
-
- // returns an instance of a class implementing this interface.
- public function __construct();
- // returns a human-friendly name for the driver (for display in installer, for example)
+ /** Returns a human-friendly name for the driver (for display in installer, for example) */
public static function driverName(): string;
- // authenticates a user against their name and password
+
+ /** Authenticates a user against their name and password */
public function auth(string $user, string $password): bool;
- // check whether a user is authorized to perform a certain action; not currently used and subject to change
- public function authorize(string $affectedUser, string $action): bool;
- // checks whether a user exists
- public function userExists(string $user): bool;
- // adds a user
- public function userAdd(string $user, string $password = null);
- // removes a user
+
+ /** Adds a new user and returns their password
+ *
+ * When given no password the implementation may return null; the user
+ * manager will then generate a random password and try again with that
+ * password. Alternatively the implementation may generate its own
+ * password if desired
+ *
+ * @param string $user The username to create
+ * @param string|null $password The cleartext password to assign to the user, or null to generate a random password
+ */
+ public function userAdd(string $user, string $password = null): ?string;
+
+ /** Renames a user
+ *
+ * The implementation must retain all user metadata as well as the
+ * user's password
+ */
+ public function userRename(string $user, string $newName): bool;
+
+ /** Removes a user */
public function userRemove(string $user): bool;
- // lists all users
+
+ /** Lists all users */
public function userList(): array;
- // sets a user's password; if the driver does not require the old password, it may be ignored
- public function userPasswordSet(string $user, string $newPassword = null, string $oldPassword = null);
- // removes a user's password; this makes authentication fail unconditionally
+
+ /** Sets a user's password
+ *
+ * When given no password the implementation may return null; the user
+ * manager will then generate a random password and try again with that
+ * password. Alternatively the implementation may generate its own
+ * password if desired
+ *
+ * @param string $user The user for whom to change the password
+ * @param string|null $password The cleartext password to assign to the user, or null to generate a random password
+ * @param string|null $oldPassword The user's previous password, if known
+ */
+ public function userPasswordSet(string $user, ?string $newPassword, string $oldPassword = null): ?string;
+
+ /** Removes a user's password; this makes authentication fail unconditionally
+ *
+ * @param string $user The user for whom to change the password
+ * @param string|null $oldPassword The user's previous password, if known
+ */
public function userPasswordUnset(string $user, string $oldPassword = null): bool;
+
+ /** Retrieves metadata about a user
+ *
+ * Any expected keys not returned by the driver are taken from the internal
+ * database instead; the expected keys at this time are:
+ *
+ * - admin: A boolean denoting whether the user has administrator privileges
+ * - lang: A BCP 47 language tag e.g. "en", "hy-Latn-IT-arevela"
+ * - tz: A zoneinfo timezone e.g. "Asia/Jakarta", "America/Argentina/La_Rioja"
+ * - sort_asc: A boolean denoting whether the user prefers articles to be sorted oldest-first
+ *
+ * Any other keys will be ignored.
+ */
+ public function userPropertiesGet(string $user, bool $includeLarge = true): array;
+
+ /** Sets metadata about a user
+ *
+ * Output should be the same as the input, unless input is changed prior to storage
+ * (if it is, for instance, normalized in some way), which which case the changes
+ * should be reflected in the output.
+ *
+ * @param string $user The user for which to set metadata
+ * @param array $data The input data; see userPropertiesGet for keys
+ */
+ public function userPropertiesSet(string $user, array $data): array;
}
diff --git a/lib/User/ExceptionNotImplemented.php b/lib/User/ExceptionConflict.php
similarity index 78%
rename from lib/User/ExceptionNotImplemented.php
rename to lib/User/ExceptionConflict.php
index 12518ac7..4fa1bbfd 100644
--- a/lib/User/ExceptionNotImplemented.php
+++ b/lib/User/ExceptionConflict.php
@@ -6,5 +6,5 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\User;
-class ExceptionNotImplemented extends Exception {
+class ExceptionConflict extends Exception {
}
diff --git a/lib/User/ExceptionAuthz.php b/lib/User/ExceptionInput.php
similarity index 81%
rename from lib/User/ExceptionAuthz.php
rename to lib/User/ExceptionInput.php
index 2d16f594..aea8c131 100644
--- a/lib/User/ExceptionAuthz.php
+++ b/lib/User/ExceptionInput.php
@@ -6,5 +6,5 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\User;
-class ExceptionAuthz extends Exception {
+class ExceptionInput extends Exception {
}
diff --git a/lib/User/Internal/Driver.php b/lib/User/Internal/Driver.php
index 4fc787f1..80f16bb3 100644
--- a/lib/User/Internal/Driver.php
+++ b/lib/User/Internal/Driver.php
@@ -7,7 +7,7 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\User\Internal;
use JKingWeb\Arsse\Arsse;
-use JKingWeb\Arsse\User\Exception;
+use JKingWeb\Arsse\User\ExceptionConflict;
class Driver implements \JKingWeb\Arsse\User\Driver {
public function __construct() {
@@ -23,7 +23,7 @@ class Driver implements \JKingWeb\Arsse\User\Driver {
if (is_null($hash)) {
return false;
}
- } catch (Exception $e) {
+ } catch (ExceptionConflict $e) {
return false;
}
if ($password === "" && $hash === "") {
@@ -32,14 +32,6 @@ class Driver implements \JKingWeb\Arsse\User\Driver {
return password_verify($password, $hash);
}
- public function authorize(string $affectedUser, string $action): bool {
- return true;
- }
-
- public function userExists(string $user): bool {
- return Arsse::$db->userExists($user);
- }
-
public function userAdd(string $user, string $password = null): ?string {
if (isset($password)) {
// only add the user if the password is not null; the user manager will retry with a generated password if null is returned
@@ -48,6 +40,16 @@ class Driver implements \JKingWeb\Arsse\User\Driver {
return $password;
}
+ public function userRename(string $user, string $newName): bool {
+ // do nothing: the internal database is updated regardless of what the driver does (assuming it does not throw an exception)
+ // throw an exception if the user does not exist
+ if (!$this->userExists($user)) {
+ throw new ExceptionConflict("doesNotExist", ['action' => __FUNCTION__, 'user' => $user]);
+ } else {
+ return !($user === $newName);
+ }
+ }
+
public function userRemove(string $user): bool {
return Arsse::$db->userRemove($user);
}
@@ -56,16 +58,21 @@ class Driver implements \JKingWeb\Arsse\User\Driver {
return Arsse::$db->userList();
}
- public function userPasswordSet(string $user, string $newPassword = null, string $oldPassword = null): ?string {
+ public function userPasswordSet(string $user, ?string $newPassword, string $oldPassword = null): ?string {
// do nothing: the internal database is updated regardless of what the driver does (assuming it does not throw an exception)
- return $newPassword;
+ // throw an exception if the user does not exist
+ if (!$this->userExists($user)) {
+ throw new ExceptionConflict("doesNotExist", ['action' => __FUNCTION__, 'user' => $user]);
+ } else {
+ return $newPassword;
+ }
}
public function userPasswordUnset(string $user, string $oldPassword = null): bool {
// do nothing: the internal database is updated regardless of what the driver does (assuming it does not throw an exception)
// throw an exception if the user does not exist
if (!$this->userExists($user)) {
- throw new Exception("doesNotExist", ['action' => "userPasswordUnset", 'user' => $user]);
+ throw new ExceptionConflict("doesNotExist", ['action' => __FUNCTION__, 'user' => $user]);
} else {
return true;
}
@@ -74,4 +81,26 @@ class Driver implements \JKingWeb\Arsse\User\Driver {
protected function userPasswordGet(string $user): ?string {
return Arsse::$db->userPasswordGet($user);
}
+
+ protected function userExists(string $user): bool {
+ return Arsse::$db->userExists($user);
+ }
+
+ public function userPropertiesGet(string $user, bool $includeLarge = true): array {
+ // do nothing: the internal database will retrieve everything for us
+ if (!$this->userExists($user)) {
+ throw new ExceptionConflict("doesNotExist", ['action' => __FUNCTION__, 'user' => $user]);
+ } else {
+ return [];
+ }
+ }
+
+ public function userPropertiesSet(string $user, array $data): array {
+ // do nothing: the internal database will set everything for us
+ if (!$this->userExists($user)) {
+ throw new ExceptionConflict("doesNotExist", ['action' => __FUNCTION__, 'user' => $user]);
+ } else {
+ return $data;
+ }
+ }
}
diff --git a/locale/en.php b/locale/en.php
index c19ac94b..a439a45b 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -6,6 +6,36 @@
return [
'CLI.Auth.Success' => 'Authentication successful',
'CLI.Auth.Failure' => 'Authentication failed',
+ 'Service.Reload' => 'Configuration reloaded',
+
+ 'API.Miniflux.DefaultCategoryName' => "All",
+ 'API.Miniflux.ImportSuccess' => 'Feeds imported successfully',
+ 'API.Miniflux.Error.401' => 'Access Unauthorized',
+ 'API.Miniflux.Error.403' => 'Access Forbidden',
+ 'API.Miniflux.Error.404' => 'Resource Not Found',
+ 'API.Miniflux.Error.MissingInputValue' => 'Required key "{field}" was not present in input',
+ 'API.Miniflux.Error.DuplicateInputValue' => 'Key "{field}" accepts only one value',
+ 'API.Miniflux.Error.InvalidBodyJSON' => 'Invalid JSON payload: {0}',
+ 'API.Miniflux.Error.InvalidBodyXML' => 'Invalid XML payload',
+ 'API.Miniflux.Error.InvalidBodyOPML' => 'Payload is not a valid OPML document',
+ 'API.Miniflux.Error.InvalidInputType' => 'Input key "{field}" of type {actual} was expected as {expected}',
+ 'API.Miniflux.Error.InvalidInputValue' => 'Supplied value is not valid for input key "{field}"',
+ 'API.Miniflux.Error.Fetch404' => 'Resource not found (404), this feed doesn\'t exists anymore, check the feed URL',
+ 'API.Miniflux.Error.Fetch401' => 'You are not authorized to access this resource (invalid username/password)',
+ 'API.Miniflux.Error.Fetch403' => 'Unable to fetch this resource (Status Code = 403)',
+ 'API.Miniflux.Error.FetchOther' => 'Unable to fetch this resource',
+ 'API.Miniflux.Error.FetchFormat' => 'Unsupported feed format',
+ 'API.Miniflux.Error.DuplicateCategory' => 'This category already exists.',
+ 'API.Miniflux.Error.InvalidCategory' => 'Invalid category title',
+ 'API.Miniflux.Error.MissingCategory' => 'This category does not exist or does not belong to this user.',
+ 'API.Miniflux.Error.InvalidElevation' => 'Only administrators can change permissions of standard users',
+ 'API.Miniflux.Error.DuplicateUser' => 'The user name "{user}" already exists',
+ 'API.Miniflux.Error.DuplicateFeed' => 'This feed already exists.',
+ 'API.Miniflux.Error.InvalidTitle' => 'Invalid feed title',
+ 'API.Miniflux.Error.InvalidImportCategory' => 'Payload contains an invalid category name',
+ 'API.Miniflux.Error.DuplicateImportCategory' => 'Payload contains the same category name twice',
+ 'API.Miniflux.Error.FailedImportFeed' => 'Unable to import feed at URL "{url}" (code {code}',
+ 'API.Miniflux.Error.InvalidImportLabel' => 'Payload contains an invalid label name',
'API.TTRSS.Category.Uncategorized' => 'Uncategorized',
'API.TTRSS.Category.Special' => 'Special',
@@ -51,6 +81,12 @@ return [
}',
// indicates programming error
'Exception.JKingWeb/Arsse/ExceptionType.typeUnknown' => 'Normalization type {0} is not implemented',
+ 'Exception.JKingWeb/Arsse/Exception.extMissing' =>
+ 'The "{first}" PHP extension {total, plural, offset:1
+ =1 {is}
+ one {and # other extension are}
+ other {and # other extensions are}
+ } not installed or not enabled.',
'Exception.JKingWeb/Arsse/Lang/Exception.defaultFileMissing' => 'Default language file "{0}" missing',
'Exception.JKingWeb/Arsse/Lang/Exception.fileMissing' => 'Language file "{0}" is not available',
'Exception.JKingWeb/Arsse/Lang/Exception.fileUnreadable' => 'Insufficient permissions to read language file "{0}"',
@@ -121,6 +157,7 @@ return [
// indicates programming error
'Exception.JKingWeb/Arsse/Db/Exception.resultReused' => 'Result set already iterated',
'Exception.JKingWeb/Arsse/Db/ExceptionRetry.schemaChange' => '{0}',
+ 'Exception.JKingWeb/Arsse/Db/ExceptionInput.invalidValue' => 'Value of field "{field}" of action "{action}" is invalid',
'Exception.JKingWeb/Arsse/Db/ExceptionInput.missing' => 'Required field "{field}" missing while performing action "{action}"',
'Exception.JKingWeb/Arsse/Db/ExceptionInput.whitespace' => 'Field "{field}" of action "{action}" may not contain only whitespace',
'Exception.JKingWeb/Arsse/Db/ExceptionInput.tooLong' => 'Field "{field}" of action "{action}" has a maximum length of {max}',
@@ -134,16 +171,26 @@ return [
'Exception.JKingWeb/Arsse/Db/ExceptionInput.engineTypeViolation' => '{0}',
'Exception.JKingWeb/Arsse/Db/ExceptionTimeout.general' => '{0}',
'Exception.JKingWeb/Arsse/Db/ExceptionTimeout.logicalLock' => 'Database is locked',
- 'Exception.JKingWeb/Arsse/User/Exception.alreadyExists' => 'Could not perform action "{action}" because the user {user} already exists',
- 'Exception.JKingWeb/Arsse/User/Exception.doesNotExist' => 'Could not perform action "{action}" because the user {user} does not exist',
+ 'Exception.JKingWeb/Arsse/User/ExceptionConflict.alreadyExists' => 'Could not perform action "{action}" because the user {user} already exists',
+ 'Exception.JKingWeb/Arsse/User/ExceptionConflict.doesNotExist' => 'Could not perform action "{action}" because the user {user} does not exist',
'Exception.JKingWeb/Arsse/User/Exception.authMissing' => 'Please log in to proceed',
'Exception.JKingWeb/Arsse/User/Exception.authFailed' => 'Authentication failed',
- 'Exception.JKingWeb/Arsse/User/ExceptionAuthz.notAuthorized' =>
- '{action, select,
- userList {Authenticated user is not authorized to view the user list}
- 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/User/ExceptionInput.invalidUsername' => 'User names may not contain the Unicode character {0}',
+ 'Exception.JKingWeb/Arsse/User/ExceptionInput.invalidValue' =>
+ 'User property "{field}" must be {type, select,
+ 1 {null}
+ 2 {true or false}
+ 3 {an integer}
+ 4 {a real number}
+ 5 {a DateTime object}
+ 6 {a string}
+ 7 {an array}
+ 8 {a DateInterval object}
+ other {another type}
+ }',
+ 'Exception.JKingWeb/Arsse/User/ExceptionInput.invalidTimezone' => 'User property "{field}" must be a valid zoneinfo timezone',
+ 'Exception.JKingWeb/Arsse/User/ExceptionInput.invalidNonZeroInteger' => 'User property "{field}" must be greater than zero',
'Exception.JKingWeb/Arsse/Feed/Exception.internalError' => 'Could not download feed "{url}" because of an internal error which is probably a bug',
'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',
@@ -167,4 +214,17 @@ return [
'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidFolderName' => 'Input data contains an invalid folder name',
'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidFolderCopy' => 'Input data contains multiple folders of the same name under the same parent',
'Exception.JKingWeb/Arsse/ImportExport/Exception.invalidTagName' => 'Input data contains an invalid tag name',
+ 'Exception.JKingWeb/Arsse/Rule/Exception.invalidPattern' => 'Specified rule pattern is invalid',
+ 'Exception.JKingWeb/Arsse/Service/Exception.pidNotFile' => 'PID file "{pidfile}" must be a regular file',
+ 'Exception.JKingWeb/Arsse/Service/Exception.pidDirMissing' => 'Parent directory "{piddir}" of PID file does not exist',
+ 'Exception.JKingWeb/Arsse/Service/Exception.pidDirUnresolvable' => 'Parent directory "{piddir}" of PID file could not be resolved to its absolute path',
+ 'Exception.JKingWeb/Arsse/Service/Exception.pidUnreadable' => 'Insufficient permissions to open PID file "{pidfile}" for reading',
+ 'Exception.JKingWeb/Arsse/Service/Exception.pidUnwritable' => 'Insufficient permissions to open PID file "{pidfile}" for writing',
+ 'Exception.JKingWeb/Arsse/Service/Exception.pidUnusable' => 'Insufficient permissions to open PID file "{pidfile}" for reading or writing',
+ 'Exception.JKingWeb/Arsse/Service/Exception.pidUncreatable' => 'Insufficient permissions to create PID file "{pidfile}"',
+ 'Exception.JKingWeb/Arsse/Service/Exception.pidCorrupt' => 'PID file "{pidfile}" does not contain a process identifier',
+ 'Exception.JKingWeb/Arsse/Service/Exception.pidDuplicate' => 'Service is already running with process identifier {pid}',
+ 'Exception.JKingWeb/Arsse/Service/Exception.pidLocked' => 'PID file "{pidfile}" is locked',
+ 'Exception.JKingWeb/Arsse/Service/Exception.pidInaccessible' => 'Unable to open PID file "{pidfile}"',
+ 'Exception.JKingWeb/Arsse/Service/Exception.forkFailed' => 'Failed to spawn child process ({instance, ordinal} instance)',
];
diff --git a/manpages/en.md b/manpages/en.md
new file mode 100644
index 00000000..4896367f
--- /dev/null
+++ b/manpages/en.md
@@ -0,0 +1,226 @@
+---
+title: "ARSSE"
+section: 1
+date: 2021-07-03
+footer: "arsse 0.10.0"
+header: "User Commands"
+---
+
+# NAME
+
+arsse - manage an instance of The Advanced RSS Environment (The Arsse)
+
+# SYNOPSIS
+
+**arsse user** [**list**]\
+**arsse user add** <_username_> [<_password_>] [**--admin**]\
+**arsse user remove** <_username_>\
+**arsse user show** <_username_>\
+**arsse user set** <_username_> <_property_> <_value_>\
+**arsse user unset** <_username_> <_property_>\
+**arsse user set-pass** <_username_> [<_password_>] [**--fever**]\
+**arsse user unset-pass** <_username_> [**--fever**]\
+**arsse user auth** <_username_> <_password_> [**--fever**]\
+**arsse token list** <_username_>\
+**arsse token create** <_username_> [<_label_>]\
+**arsse token revoke** <_username_> [<_token_>]\
+**arsse import** <_username_> [<_file_>] [**-f**|**--flat**] [**-r**|**--replace**]\
+**arsse export** <_username_> [<_file_>] [**-f**|**--flat**]\
+**arsse daemon** [**--fork=**<_pidfile_>]\
+**arsse feed refresh-all**\
+**arsse feed refresh** <_n_>\
+**arsse conf save-defaults** [<_file_>]\
+**arsse --version**\
+**arsse -h**|**--help**
+
+# DESCRIPTION
+
+**arsse** allows a sufficiently privileged user to perform various administrative operations related to The Arsse, including:
+
+- Adding and removing users and managing their metadata
+- Managing passwords and authentication tokens
+- Importing and exporting OPML newsfeed-lists
+
+These are documented in the next section **PRIMARY COMMANDS**. Further, seldom-used commands are documented in the following section **ADDITIONAL COMMANDS**.
+
+# PRIMARY COMMANDS
+
+## Managing users and metadata
+
+**arsse user [list]**
+
+: Displays a simple list of user names with one entry per line
+
+**arsse user add** <_username_> [<_password_>] [**--admin**]
+
+: Adds a new user to the database with the specified username and password. If <_password_> is omitted a random password will be generated and printed.
+
+ The **--admin** flag may be used to mark the user as an administrator. This has no meaning within the context of The Arsse as a whole, but it is used control access to certain features in the Miniflux and Nextcloud News protocols.
+
+**arsse user remove** <_username_>
+
+: Immediately removes a user from the database. All associated data (folders, subscriptions, etc.) are also removed.
+
+**arsse user show** <_username_>
+
+: Displays a table of metadata properties and their assigned values for <_username_>. These properties are primarily used by the Miniflux protocol. Consult the section **USER METADATA** for details.
+
+**arsse user set** <_username_> <_property_> <_value_>
+
+: Sets a metadata property for a user. These properties are primarily used by the Miniflux protocol. Consult the section **USER METADATA** for details.
+
+**arsse user unset** <_username_> <_property_>
+
+: Clears a metadata property for a user. The property is thereafter set to its default value, which is protocol-dependent.
+
+## Managing passwords and authentication tokens
+
+**arsse user set-pass** <_username_> [<_password_>] [**--fever**]
+
+: Changes a user's password to the specified value. If no password is specified, a random password will be generated and printed.
+
+ The **--fever** option sets a user's Fever protocol password instead of their general password. As the Fever protocol requires that passwords be stored insecurely, users do not have Fever passwords by default, and logging in to the Fever protocol is disabled until a suitable password is set. It is highly recommended that a user's Fever password be different from their general password.
+
+**arsse user unset-pass** <_username_> [**--fever**]
+
+: Unsets a user's password, effectively disabling their account. As with password setting, the **--fever** option may be used to operate on a user's Fever password instead of their general password.
+
+**arsse user auth** <_username_> <_password_> [**--fever**]
+
+: Tests logging a user in. This only checks that the user's password is correctly recognized; it has no side effects.
+
+ The **--fever** option may be used to test the user's Fever protocol password, if any.
+
+**arsse token list** <_username_>
+
+: Displays a user's authentication tokens in a simple tabular format. These tokens act as an alternative means of authentication for the Miniflux protocol and may be required by some clients. They do not expire.
+
+**arsse token create** <_username_> [<_label_>]
+
+: Creates a new random login token and prints it. These tokens act as an alternative means of authentication for the Miniflux protocol and may be required by some clients. An optional <_label_> may be specified to give the token a meaningful name.
+
+**arsse token revoke** <_username_> [<_token_>]
+
+: Deletes the specified token from the database. The token itself must be supplied, not its label. If it is omitted all tokens are revoked.
+
+## Importing and exporting data
+
+**arsse import** <_username_> [<_file_>] [**-r**|**--replace**] [**-f**|**--flat**]
+
+: Imports the newsfeeds, folders, and tags found in the OPML formatted <_file_> into the account of the specified user. If no file is specified, data is instead read from standard input. Import operations are atomic: if any of the newsfeeds listed in the input cannot be retrieved, the entire import operation will fail.
+
+ The **--replace** (or **-r**) option interprets the OPML file as the list of **all** desired newsfeeds, folders and tags, performing any deletion or moving of existing entries which do not appear in the flle. If this option is not specified, the file is assumed to list desired **additions** only.
+
+ The **--flat** (or **-f**) option can be used to ignore any folder structures in the file, importing any newsfeeds directly into the root folder. Combining this with the **--replace** option is possible.
+
+**arsse export** <_username_> [<_file_>] [**-f**|**--flat**]
+
+: Exports a user's newsfeeds, folders, and tags to the OPML file specified by <_file_>, or standard output if no file is specified. Note that due to a limitation of the OPML format, any commas present in tag names will not be retained in the export.
+
+ The **--flat** (or **-f**) option can be used to omit folders from the export. Some OPML implementations may not support folders, or arbitrary nesting; this option may be used when planning to import into such software.
+
+# ADDITIONAL COMMANDS
+
+**arsse daemon** [**--fork=**<_pidfile_>]
+
+: Starts the newsfeed fetching service. Normally this command is only invoked by Systemd.
+
+ The **--fork** option executes an "old-style" fork-then-terminate daemon rather than a "new-style" non-terminating daemon. This option should only be employed if using a System V-style init daemon on POSIX systems; normally Systemd is used. When using this option the daemon will write its process identifier to <_pidfile_> after forking.
+
+**arsse feed refresh-all**
+
+: Performs a one-time fetch of all stale feeds. This command can be used as the basis of a **cron** job to keep newsfeeds up-to-date.
+
+**arsse feed refresh** <_n_>
+
+: Performs a one-time fetch of the feed (not subscription) identified by integer <_n_>. This is used internally by the fetching service and should not normally be needed.
+
+**arsse conf save-defaults** [<_file_>]
+
+: Prints default configuration parameters to standard output, or to <_file_> if specified. Each parameter is annotated with a short description of its purpose and usage.
+
+# USER METADATA
+
+User metadata are primarily used by the Miniflux protocol, and most properties have identical or similar names to those used by Miniflux. Properties may also affect other protocols, or conversely may have no effect even when using the Miniflux protocol; this is noted below when appropriate.
+
+Booleans accept any of the values **true**/**false**, **1**/**0**, **yes**/**no**, or **on**/**off**.
+
+The following metadata properties exist for each user:
+
+**num**
+: Integer. The numeric identifier of the user. This is assigned at user creation and is read-only.
+
+**admin**
+: Boolean. Whether the user is an administrator. Administrators may manage other users via the Miniflux protocol, and also may trigger feed updates manually via the Nextcloud News protocol.
+
+**lang**
+: String. The preferred language of the user, as a BCP 47 language tag e.g. "en-ca". Note that since The Arsse currently only includes English text it is not used by The Arsse itself, but clients may use this metadatum in protocols which expose it.
+
+**tz**
+: String. The time zone of the user, as a tzdata identifier e.g. "America/Los_Angeles".
+
+**root_folder_name**
+: String. The name of the root folder, in protocols which allow it to be renamed.
+
+**sort_asc**
+: Boolean. Whether the user prefers ascending sort order for articles. Descending order is usually the default, but explicitly setting this property false will also make a preference for descending order explicit.
+
+**theme**
+: String. The user's preferred theme. This is not used by The Arsse itself, but clients may use this metadatum in protocols which expose it.
+
+**page_size**
+: Integer. The user's preferred page size when listing articles. This is not used by The Arsse itself, but clients may use this metadatum in protocols which expose it.
+
+**shortcuts**
+: Boolean. Whether to enable keyboard shortcuts. This is not used by The Arsse itself, but clients may use this metadatum in protocols which expose it.
+
+**gestures**
+: Boolean. Whether to enable touch gestures. This is not used by The Arsse itself, but clients may use this metadatum in protocols which expose it.
+
+**reading_time**
+: Boolean. Whether to calculate and display the estimated reading time for articles. Currently The Arsse does not calculate reading time, so changing this will likely have no effect.
+
+**stylesheet**
+: String. A user CSS stylesheet. This is not used by The Arsse itself, but clients may use this metadatum in protocols which expose it.
+
+# EXAMPLES
+
+- Add an administrator to the database with an explicit password
+
+ $ arsse user add --admin alice "Curiouser and curiouser!"
+
+- Add a regular user to the database with a random password
+
+ $ arsse user add "Bob the Builder"
+ bLS!$_UUZ!iN2i_!^IC6
+
+- Make Bob the Builder an administrator
+
+ $ arsse user set "Bob the Builder" admin true
+
+- Disable Alice's account by clearing her password
+
+ $ arsse user unset-pass alice
+
+- Move all of Foobar's newsfeeds to the root folder
+
+ $ arsse export foobar -f | arsse import -r foobar
+
+- Fail to log in as Alice
+
+ $ arsse user auth alice "Oh, dear!"
+ Authentication failed
+ $ echo $?
+ 1
+
+# REPORTING BUGS
+
+Any bugs found in The Arsse may be reported on the Web at [https://code.mensbeam.com/MensBeam/arsse](). Reports may also be directed to the authors (below) by e-mail.
+
+# AUTHORS
+
+J. King\
+[https://jkingweb.ca/]()
+
+Dustin Wilson\
+[https://dustinwilson.com/]()
diff --git a/package.json b/package.json
index e7a3b351..96eab27f 100644
--- a/package.json
+++ b/package.json
@@ -1,17 +1,17 @@
{
"devDependencies": {
- "autoprefixer": "^9.6.1",
- "postcss": "^7.0.0",
- "postcss-cli": "^7.1.1",
- "postcss-color-function": "^4.1.0",
- "postcss-csso": "^4.0.0",
- "postcss-custom-media": "^7.0.8",
- "postcss-custom-properties": "^9.0.2",
- "postcss-discard-comments": "^4.0.2",
- "postcss-import": "^12.0.1",
- "postcss-media-minmax": "^4.0.0",
- "postcss-nested": "^4.1.2",
- "postcss-sassy-mixins": "^2.1.0",
- "postcss-scss": "^2.0.0"
+ "autoprefixer": "*",
+ "postcss": "*",
+ "postcss-cli": "*",
+ "postcss-color-function": "*",
+ "postcss-csso": "*",
+ "postcss-custom-media": "*",
+ "postcss-custom-properties": "*",
+ "postcss-discard-comments": "*",
+ "postcss-import": "*",
+ "postcss-media-minmax": "*",
+ "postcss-nested": "*",
+ "postcss-sassy-mixins": "*",
+ "postcss-scss": "*"
}
}
diff --git a/sql/MySQL/6.sql b/sql/MySQL/6.sql
index 27be4960..789900ef 100644
--- a/sql/MySQL/6.sql
+++ b/sql/MySQL/6.sql
@@ -6,4 +6,51 @@
alter table arsse_tokens add column data longtext default null;
+alter table arsse_subscriptions add column keep_rule longtext default null;
+alter table arsse_subscriptions add column block_rule longtext default null;
+alter table arsse_marks add column hidden boolean not null default 0;
+
+alter table arsse_users add column num bigint unsigned unique;
+alter table arsse_users add column admin boolean not null default 0;
+create temporary table arsse_users_existing(
+ id text not null,
+ num serial primary key
+) character set utf8mb4 collate utf8mb4_unicode_ci;
+insert into arsse_users_existing(id) select id from arsse_users;
+update arsse_users as u, arsse_users_existing as n
+ set u.num = n.num
+where u.id = n.id;
+drop table arsse_users_existing;
+alter table arsse_users modify num bigint unsigned not null;
+
+create table arsse_user_meta(
+ owner varchar(255) not null,
+ "key" varchar(255) not null,
+ modified datetime(0) not null default CURRENT_TIMESTAMP,
+ value longtext,
+ foreign key(owner) references arsse_users(id) on delete cascade on update cascade,
+ primary key(owner,"key")
+) character set utf8mb4 collate utf8mb4_unicode_ci;
+
+alter table arsse_subscriptions add column scrape boolean not null default 0;
+update arsse_subscriptions set scrape = 1 where feed in (select id from arsse_feeds where scrape = 1);
+alter table arsse_feeds drop column scrape;
+alter table arsse_articles add column content_scraped longtext;
+
+create table arsse_icons(
+ id serial primary key,
+ url varchar(767) unique not null,
+ modified datetime(0),
+ etag varchar(255) not null default '',
+ next_fetch datetime(0),
+ orphaned datetime(0),
+ type text,
+ data longblob
+) character set utf8mb4 collate utf8mb4_unicode_ci;
+insert into arsse_icons(url) select distinct favicon from arsse_feeds where favicon is not null and favicon <> '';
+alter table arsse_feeds add column icon bigint unsigned;
+alter table arsse_feeds add constraint foreign key (icon) references arsse_icons(id) on delete set null;
+update arsse_feeds as f, arsse_icons as i set f.icon = i.id where f.favicon = i.url;
+alter table arsse_feeds drop column favicon;
+
update arsse_meta set value = '7' where "key" = 'schema_version';
diff --git a/sql/PostgreSQL/6.sql b/sql/PostgreSQL/6.sql
index 3271e5bb..0f559a87 100644
--- a/sql/PostgreSQL/6.sql
+++ b/sql/PostgreSQL/6.sql
@@ -6,4 +6,50 @@
alter table arsse_tokens add column data text default null;
+alter table arsse_subscriptions add column keep_rule text default null;
+alter table arsse_subscriptions add column block_rule text default null;
+alter table arsse_marks add column hidden smallint not null default 0;
+
+alter table arsse_users add column num bigint unique;
+alter table arsse_users add column admin smallint not null default 0;
+create temp table arsse_users_existing(
+ id text not null,
+ num bigserial
+);
+insert into arsse_users_existing(id) select id from arsse_users;
+update arsse_users as u
+ set num = e.num
+from arsse_users_existing as e
+where u.id = e.id;
+drop table arsse_users_existing;
+alter table arsse_users alter column num set not null;
+
+create table arsse_user_meta(
+ owner text not null references arsse_users(id) on delete cascade on update cascade,
+ key text not null,
+ modified timestamp(0) without time zone not null default CURRENT_TIMESTAMP,
+ value text,
+ primary key(owner,key)
+);
+
+alter table arsse_subscriptions add column scrape smallint not null default 0;
+update arsse_subscriptions set scrape = 1 where feed in (select id from arsse_feeds where scrape = 1);
+alter table arsse_feeds drop column scrape;
+alter table arsse_articles add column content_scraped text;
+
+create table arsse_icons(
+ id bigserial primary key,
+ url text unique not null,
+ modified timestamp(0) without time zone,
+ etag text not null default '',
+ next_fetch timestamp(0) without time zone,
+ orphaned timestamp(0) without time zone,
+ type text,
+ data bytea
+);
+insert into arsse_icons(url) select distinct favicon from arsse_feeds where favicon is not null and favicon <> '';
+alter table arsse_feeds add column icon bigint references arsse_icons(id) on delete set null;
+update arsse_feeds as f set icon = i.id from arsse_icons as i where f.favicon = i.url;
+alter table arsse_feeds drop column favicon;
+
update arsse_meta set value = '7' where "key" = 'schema_version';
diff --git a/sql/SQLite3/6.sql b/sql/SQLite3/6.sql
index 3902d112..2be4fed5 100644
--- a/sql/SQLite3/6.sql
+++ b/sql/SQLite3/6.sql
@@ -2,9 +2,115 @@
-- Copyright 2017 J. King, Dustin Wilson et al.
-- See LICENSE and AUTHORS files for details
--- add a column to the token table to hold arbitrary class-specific data
+-- Add a column to the token table to hold arbitrary class-specific data
+-- This is a speculative addition to support OAuth login in the future
alter table arsse_tokens add column data text default null;
+-- Add columns to subscriptions to store "keep" and "block" filtering rules from Miniflux,
+-- as well as a column to mark articles as hidden for users
+alter table arsse_subscriptions add column keep_rule text default null;
+alter table arsse_subscriptions add column block_rule text default null;
+alter table arsse_marks add column hidden boolean not null default 0;
+
+-- Add numeric identifier and admin columns to the users table
+create table arsse_users_new(
+-- users
+ id text primary key not null collate nocase, -- user id
+ password text, -- password, salted and hashed; if using external authentication this would be blank
+ num integer unique not null, -- numeric identfier used by Miniflux
+ admin boolean not null default 0 -- Whether the user is an administrator
+) without rowid;
+create temp table arsse_users_existing(
+ id text not null,
+ num integer primary key
+);
+insert into arsse_users_existing(id) select id from arsse_users;
+insert into arsse_users_new(id, password, num)
+ select id, password, num
+ from arsse_users
+ join arsse_users_existing using(id);
+drop table arsse_users;
+drop table arsse_users_existing;
+alter table arsse_users_new rename to arsse_users;
+
+-- Add a table for other user metadata
+create table arsse_user_meta(
+ -- Metadata for users
+ -- It is up to individual applications (i.e. the client protocols) to cooperate with names and types
+ owner text not null references arsse_users(id) on delete cascade on update cascade, -- the user to whom the metadata belongs
+ key text not null, -- metadata key
+ modified text not null default CURRENT_TIMESTAMP, -- time at which the metadata was last changed
+ value text, -- metadata value
+ primary key(owner,key)
+) without rowid;
+
+-- Add a "scrape" column for subscriptions and copy any existing scraping
+alter table arsse_subscriptions add column scrape boolean not null default 0;
+update arsse_subscriptions set scrape = 1 where feed in (select id from arsse_feeds where scrape = 1);
+
+-- Add a column for scraped article content, and re-order some columns
+create table arsse_articles_new(
+-- entries in newsfeeds
+ id integer primary key, -- sequence number
+ feed integer not null references arsse_feeds(id) on delete cascade, -- feed for the subscription
+ url text, -- URL of article
+ title text collate nocase, -- article title
+ author text collate nocase, -- author's name
+ published text, -- time of original publication
+ edited text, -- time of last edit by author
+ modified text not null default CURRENT_TIMESTAMP, -- time when article was last modified in database
+ guid text, -- GUID
+ url_title_hash text not null, -- hash of URL + title; used when checking for updates and for identification if there is no guid.
+ url_content_hash text not null, -- hash of URL + content, enclosure URL, & content type; used when checking for updates and for identification if there is no guid.
+ title_content_hash text not null, -- hash of title + content, enclosure URL, & content type; used when checking for updates and for identification if there is no guid.
+ content_scraped text, -- scraped content, as HTML
+ content text -- content, as HTML
+);
+insert into arsse_articles_new select id, feed, url, title, author, published, edited, modified, guid, url_title_hash, url_content_hash, title_content_hash, null, content from arsse_articles;
+drop table arsse_articles;
+alter table arsse_articles_new rename to arsse_articles;
+
+-- Add a separate table for feed icons and replace their URLs in the feeds table with their IDs
+-- Also remove the "scrape" column of the feeds table, which was never an advertised feature
+create table arsse_icons(
+ -- Icons associated with feeds
+ -- At a minimum the URL of the icon must be known, but its content may be missing
+ id integer primary key, -- the identifier for the icon
+ url text unique not null, -- the URL of the icon
+ modified text, -- Last-Modified date, for caching
+ etag text not null default '', -- ETag, for caching
+ next_fetch text, -- The date at which cached data should be considered stale
+ orphaned text, -- time at which the icon last had no feeds associated with it
+ type text, -- the Content-Type of the icon, if known
+ data blob -- the binary data of the icon itself
+);
+insert into arsse_icons(url) select distinct favicon from arsse_feeds where favicon is not null and favicon <> '';
+create table arsse_feeds_new(
+-- newsfeeds, deduplicated
+-- users have subscriptions to these feeds in another table
+ id integer primary key, -- sequence number
+ url text not null, -- URL of feed
+ title text collate nocase, -- default title of feed (users can set the title of their subscription to the feed)
+ source text, -- URL of site to which the feed belongs
+ updated text, -- time at which the feed was last fetched
+ modified text, -- time at which the feed last actually changed
+ next_fetch text, -- time at which the feed should next be fetched
+ orphaned text, -- time at which the feed last had no subscriptions
+ etag text not null default '', -- HTTP ETag hash used for cache validation, changes each time the content changes
+ err_count integer not null default 0, -- count of successive times update resulted in error since last successful update
+ err_msg text, -- last error message
+ username text not null default '', -- HTTP authentication username
+ password text not null default '', -- HTTP authentication password (this is stored in plain text)
+ size integer not null default 0, -- number of articles in the feed at last fetch
+ icon integer references arsse_icons(id) on delete set null, -- numeric identifier of any associated icon
+ unique(url,username,password) -- a URL with particular credentials should only appear once
+);
+insert into arsse_feeds_new
+ select f.id, f.url, title, source, updated, f.modified, f.next_fetch, f.orphaned, f.etag, err_count, err_msg, username, password, size, i.id
+ from arsse_feeds as f left join arsse_icons as i on f.favicon = i.url;
+drop table arsse_feeds;
+alter table arsse_feeds_new rename to arsse_feeds;
+
-- set version marker
pragma user_version = 7;
update arsse_meta set value = '7' where "key" = 'schema_version';
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 2e4c2514..e17ef0f9 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -12,9 +12,16 @@ const DOCROOT = BASE."tests".DIRECTORY_SEPARATOR."docroot".DIRECTORY_SEPARATOR;
ini_set("memory_limit", "-1");
ini_set("zend.assertions", "1");
ini_set("assert.exception", "true");
-error_reporting(\E_ALL);
+// FIXME: Workaround for a bug in PCRE2 10.37
+ini_set("pcre.jit", "0");
+// FIXME: This is required by a dependency of Picofeed
+error_reporting(\E_ALL & ~\E_DEPRECATED);
require_once BASE."vendor".DIRECTORY_SEPARATOR."autoload.php";
if (function_exists("xdebug_set_filter")) {
- xdebug_set_filter(\XDEBUG_FILTER_CODE_COVERAGE, \XDEBUG_PATH_WHITELIST, [BASE."lib/"]);
+ if (defined("XDEBUG_PATH_INCLUDE")) {
+ xdebug_set_filter(\XDEBUG_FILTER_CODE_COVERAGE, XDEBUG_PATH_INCLUDE, [BASE."lib/"]);
+ } else {
+ xdebug_set_filter(\XDEBUG_FILTER_CODE_COVERAGE, XDEBUG_PATH_WHITELIST, [BASE."lib/"]);
+ }
}
diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php
index e6c19e2d..a17ecbb5 100644
--- a/tests/cases/CLI/TestCLI.php
+++ b/tests/cases/CLI/TestCLI.php
@@ -6,6 +6,7 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\CLI;
+use Eloquent\Phony\Phpunit\Phony;
use GuzzleHttp\Exception\ClientException;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Conf;
@@ -14,18 +15,23 @@ use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Service;
use JKingWeb\Arsse\CLI;
use JKingWeb\Arsse\REST\Fever\User as FeverUser;
+use JKingWeb\Arsse\REST\Miniflux\Token as MinifluxToken;
use JKingWeb\Arsse\ImportExport\OPML;
+use JKingWeb\Arsse\Service\Daemon;
/** @covers \JKingWeb\Arsse\CLI */
class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
public function setUp(): void {
- self::clearData();
- $this->cli = \Phake::partialMock(CLI::class);
- \Phake::when($this->cli)->logError->thenReturn(null);
- \Phake::when($this->cli)->loadConf->thenReturn(true);
+ parent::setUp();
+ $this->cli = $this->partialMock(CLI::class);
+ $this->cli->logError->returns(null);
+ $this->cli->loadConf->returns(true);
+ $this->dbMock = $this->mock(Database::class);
}
- public function assertConsole(CLI $cli, string $command, int $exitStatus, string $output = "", bool $pattern = false): void {
+ public function assertConsole(string $command, int $exitStatus, string $output = "", bool $pattern = false): void {
+ Arsse::$obj = $this->objMock->get();
+ Arsse::$db = $this->dbMock->get();
$argv = \Clue\Arguments\split($command);
$output = strlen($output) ? $output.\PHP_EOL : "";
if ($pattern) {
@@ -33,18 +39,18 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
} else {
$this->expectOutputString($output);
}
- $this->assertSame($exitStatus, $cli->dispatch($argv));
+ $this->assertSame($exitStatus, $this->cli->get()->dispatch($argv));
}
public function testPrintVersion(): void {
- $this->assertConsole($this->cli, "arsse.php --version", 0, Arsse::VERSION);
- \Phake::verify($this->cli, \Phake::times(0))->loadConf;
+ $this->assertConsole("arsse.php --version", 0, Arsse::VERSION);
+ $this->cli->loadConf->never()->called();
}
/** @dataProvider provideHelpText */
public function testPrintHelp(string $cmd, string $name): void {
- $this->assertConsole($this->cli, $cmd, 0, str_replace("arsse.php", $name, CLI::USAGE));
- \Phake::verify($this->cli, \Phake::times(0))->loadConf;
+ $this->assertConsole($cmd, 0, str_replace("arsse.php", $name, CLI::USAGE));
+ $this->cli->loadConf->never()->called();
}
public function provideHelpText(): iterable {
@@ -59,33 +65,64 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testStartTheDaemon(): void {
- $srv = \Phake::mock(Service::class);
- \Phake::when($srv)->watch->thenReturn(new \DateTimeImmutable);
- \Phake::when($this->cli)->getInstance(Service::class)->thenReturn($srv);
- $this->assertConsole($this->cli, "arsse.php daemon", 0);
- \Phake::verify($this->cli)->loadConf;
- \Phake::verify($srv)->watch(true);
- \Phake::verify($this->cli)->getInstance(Service::class);
+ $srv = $this->mock(Service::class);
+ $srv->watch->returns(new \DateTimeImmutable);
+ $this->objMock->get->with(Service::class)->returns($srv->get());
+ $this->assertConsole("arsse.php daemon", 0);
+ $this->cli->loadConf->called();
+ $srv->watch->calledWith(true);
+ }
+
+ public function testStartTheForkingDaemon(): void {
+ $f = tempnam(sys_get_temp_dir(), "arsse");
+ $srv = $this->mock(Service::class);
+ $srv->watch->returns(new \DateTimeImmutable);
+ $daemon = $this->mock(Daemon::class);
+ $daemon->checkPIDFilePath->returns($f);
+ $daemon->fork->returns(null);
+ $this->objMock->get->with(Service::class)->returns($srv->get());
+ $this->objMock->get->with(Daemon::class)->returns($daemon->get());
+ $this->assertConsole("arsse.php daemon --fork=arsse.pid", 0);
+ $this->assertFileDoesNotExist($f);
+ Phony::inOrder(
+ $daemon->checkPIDFilePath->calledWith("arsse.pid"),
+ $daemon->fork->calledWith($f),
+ $this->cli->loadConf->called(),
+ $srv->watch->calledWith(true)
+ );
+ }
+
+ public function testFailToStartTheForkingDaemon(): void {
+ $srv = $this->mock(Service::class);
+ $srv->watch->returns(new \DateTimeImmutable);
+ $daemon = $this->mock(Daemon::class);
+ $daemon->checkPIDFilePath->throws(new Service\Exception("pidDuplicate", ['pid' => 2112]));
+ $daemon->fork->returns(null);
+ $this->objMock->get->with(Service::class)->returns($srv->get());
+ $this->objMock->get->with(Daemon::class)->returns($daemon->get());
+ $this->assertConsole("arsse.php daemon --fork=arsse.pid", 10809);
+ $daemon->checkPIDFilePath->calledWith("arsse.pid");
+ $daemon->fork->never()->called();
+ $this->cli->loadConf->never()->called();
+ $srv->watch->never()->called();
}
public function testRefreshAllFeeds(): void {
- $srv = \Phake::mock(Service::class);
- \Phake::when($srv)->watch->thenReturn(new \DateTimeImmutable);
- \Phake::when($this->cli)->getInstance(Service::class)->thenReturn($srv);
- $this->assertConsole($this->cli, "arsse.php feed refresh-all", 0);
- \Phake::verify($this->cli)->loadConf;
- \Phake::verify($srv)->watch(false);
- \Phake::verify($this->cli)->getInstance(Service::class);
+ $srv = $this->mock(Service::class);
+ $srv->watch->returns(new \DateTimeImmutable);
+ $this->objMock->get->with(Service::class)->returns($srv->get());
+ $this->assertConsole("arsse.php feed refresh-all", 0);
+ $this->cli->loadConf->called();
+ $srv->watch->calledWith(false);
}
/** @dataProvider provideFeedUpdates */
public function testRefreshAFeed(string $cmd, int $exitStatus, string $output): void {
- Arsse::$db = \Phake::mock(Database::class);
- \Phake::when(Arsse::$db)->feedUpdate(1, true)->thenReturn(true);
- \Phake::when(Arsse::$db)->feedUpdate(2, true)->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.com/", $this->mockGuzzleException(ClientException::class, "", 404)));
- $this->assertConsole($this->cli, $cmd, $exitStatus, $output);
- \Phake::verify($this->cli)->loadConf;
- \Phake::verify(Arsse::$db)->feedUpdate;
+ $this->dbMock->feedUpdate->with(1, true)->returns(true);
+ $this->dbMock->feedUpdate->with(2, true)->throws(new \JKingWeb\Arsse\Feed\Exception("", ['url' => "http://example.com/"], $this->mockGuzzleException(ClientException::class, "", 404)));
+ $this->assertConsole($cmd, $exitStatus, $output);
+ $this->cli->loadConf->called();
+ $this->dbMock->feedUpdate->called();
}
public function provideFeedUpdates(): iterable {
@@ -97,14 +134,14 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideDefaultConfigurationSaves */
public function testSaveTheDefaultConfiguration(string $cmd, int $exitStatus, string $file): void {
- $conf = \Phake::mock(Conf::class);
- \Phake::when($conf)->exportFile("php://output", true)->thenReturn(true);
- \Phake::when($conf)->exportFile("good.conf", true)->thenReturn(true);
- \Phake::when($conf)->exportFile("bad.conf", true)->thenThrow(new \JKingWeb\Arsse\Conf\Exception("fileUnwritable"));
- \Phake::when($this->cli)->getInstance(Conf::class)->thenReturn($conf);
- $this->assertConsole($this->cli, $cmd, $exitStatus);
- \Phake::verify($this->cli, \Phake::times(0))->loadConf;
- \Phake::verify($conf)->exportFile($file, true);
+ $conf = $this->mock(Conf::class);
+ $conf->exportFile->with("php://output", true)->returns(true);
+ $conf->exportFile->with("good.conf", true)->returns(true);
+ $conf->exportFile->with("bad.conf", true)->throws(new \JKingWeb\Arsse\Conf\Exception("fileUnwritable"));
+ $this->objMock->get->with(Conf::class)->returns($conf->get());
+ $this->assertConsole($cmd, $exitStatus);
+ $this->cli->loadConf->never()->called();
+ $conf->exportFile->calledWith($file, true);
}
public function provideDefaultConfigurationSaves(): iterable {
@@ -121,7 +158,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
// FIXME: Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead
Arsse::$user = $this->createMock(User::class);
Arsse::$user->method("list")->willReturn($list);
- $this->assertConsole($this->cli, $cmd, $exitStatus, $output);
+ $this->assertConsole($cmd, $exitStatus, $output);
}
public function provideUserList(): iterable {
@@ -142,12 +179,12 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
Arsse::$user->method("add")->will($this->returnCallback(function($user, $pass = null) {
switch ($user) {
case "john.doe@example.com":
- throw new \JKingWeb\Arsse\User\Exception("alreadyExists");
+ throw new \JKingWeb\Arsse\User\ExceptionConflict("alreadyExists");
case "jane.doe@example.com":
return is_null($pass) ? "random password" : $pass;
}
}));
- $this->assertConsole($this->cli, $cmd, $exitStatus, $output);
+ $this->assertConsole($cmd, $exitStatus, $output);
}
public function provideUserAdditions(): iterable {
@@ -158,6 +195,15 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
];
}
+ public function testAddAUserAsAdministrator(): void {
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("add")->willReturn("random password");
+ Arsse::$user->method("propertiesSet")->willReturn([]);
+ Arsse::$user->expects($this->exactly(1))->method("add")->with("jane.doe@example.com", null);
+ Arsse::$user->expects($this->exactly(1))->method("propertiesSet")->with("jane.doe@example.com", ['admin' => true]);
+ $this->assertConsole("arsse.php user add jane.doe@example.com --admin", 0, "random password");
+ }
+
/** @dataProvider provideUserAuthentication */
public function testAuthenticateAUser(string $cmd, int $exitStatus, string $output): void {
// FIXME: Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead
@@ -168,12 +214,12 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
($user === "jane.doe@example.com" && $pass === "superman")
;
}));
- $fever = \Phake::mock(FeverUser::class);
- \Phake::when($fever)->authenticate->thenReturn(false);
- \Phake::when($fever)->authenticate("john.doe@example.com", "ashalla")->thenReturn(true);
- \Phake::when($fever)->authenticate("jane.doe@example.com", "thx1138")->thenReturn(true);
- \Phake::when($this->cli)->getInstance(FeverUser::class)->thenReturn($fever);
- $this->assertConsole($this->cli, $cmd, $exitStatus, $output);
+ $fever = $this->mock(FeverUser::class);
+ $fever->authenticate->returns(false);
+ $fever->authenticate->with("john.doe@example.com", "ashalla")->returns(true);
+ $fever->authenticate->with("jane.doe@example.com", "thx1138")->returns(true);
+ $this->objMock->get->with(FeverUser::class)->returns($fever->get());
+ $this->assertConsole($cmd, $exitStatus, $output);
}
public function provideUserAuthentication(): iterable {
@@ -200,9 +246,9 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
if ($user === "john.doe@example.com") {
return true;
}
- throw new \JKingWeb\Arsse\User\Exception("doesNotExist");
+ throw new \JKingWeb\Arsse\User\ExceptionConflict("doesNotExist");
}));
- $this->assertConsole($this->cli, $cmd, $exitStatus, $output);
+ $this->assertConsole($cmd, $exitStatus, $output);
}
public function provideUserRemovals(): iterable {
@@ -217,7 +263,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
$passwordChange = function($user, $pass = null) {
switch ($user) {
case "jane.doe@example.com":
- throw new \JKingWeb\Arsse\User\Exception("doesNotExist");
+ throw new \JKingWeb\Arsse\User\ExceptionConflict("doesNotExist");
case "john.doe@example.com":
return is_null($pass) ? "random password" : $pass;
}
@@ -225,10 +271,10 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
// FIXME: Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead
Arsse::$user = $this->createMock(User::class);
Arsse::$user->method("passwordSet")->will($this->returnCallback($passwordChange));
- $fever = \Phake::mock(FeverUser::class);
- \Phake::when($fever)->register->thenReturnCallback($passwordChange);
- \Phake::when($this->cli)->getInstance(FeverUser::class)->thenReturn($fever);
- $this->assertConsole($this->cli, $cmd, $exitStatus, $output);
+ $fever = $this->mock(FeverUser::class);
+ $fever->register->does($passwordChange);
+ $this->objMock->get->with(FeverUser::class)->returns($fever->get());
+ $this->assertConsole($cmd, $exitStatus, $output);
}
public function provideUserPasswordChanges(): iterable {
@@ -247,7 +293,7 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
$passwordClear = function($user) {
switch ($user) {
case "jane.doe@example.com":
- throw new \JKingWeb\Arsse\User\Exception("doesNotExist");
+ throw new \JKingWeb\Arsse\User\ExceptionConflict("doesNotExist");
case "john.doe@example.com":
return true;
}
@@ -255,10 +301,10 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
// FIXME: Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead
Arsse::$user = $this->createMock(User::class);
Arsse::$user->method("passwordUnset")->will($this->returnCallback($passwordClear));
- $fever = \Phake::mock(FeverUser::class);
- \Phake::when($fever)->unregister->thenReturnCallback($passwordClear);
- \Phake::when($this->cli)->getInstance(FeverUser::class)->thenReturn($fever);
- $this->assertConsole($this->cli, $cmd, $exitStatus, $output);
+ $fever = $this->mock(FeverUser::class);
+ $fever->unregister->does($passwordClear);
+ $this->objMock->get->with(FeverUser::class)->returns($fever->get());
+ $this->assertConsole($cmd, $exitStatus, $output);
}
public function provideUserPasswordClearings(): iterable {
@@ -272,14 +318,14 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideOpmlExports */
public function testExportToOpml(string $cmd, int $exitStatus, string $file, string $user, bool $flat): void {
- $opml = \Phake::mock(OPML::class);
- \Phake::when($opml)->exportFile("php://output", $user, $flat)->thenReturn(true);
- \Phake::when($opml)->exportFile("good.opml", $user, $flat)->thenReturn(true);
- \Phake::when($opml)->exportFile("bad.opml", $user, $flat)->thenThrow(new \JKingWeb\Arsse\ImportExport\Exception("fileUnwritable"));
- \Phake::when($this->cli)->getInstance(OPML::class)->thenReturn($opml);
- $this->assertConsole($this->cli, $cmd, $exitStatus);
- \Phake::verify($this->cli)->loadConf;
- \Phake::verify($opml)->exportFile($file, $user, $flat);
+ $opml = $this->mock(OPML::class);
+ $opml->exportFile->with("php://output", $user, $flat)->returns(true);
+ $opml->exportFile->with("good.opml", $user, $flat)->returns(true);
+ $opml->exportFile->with("bad.opml", $user, $flat)->throws(new \JKingWeb\Arsse\ImportExport\Exception("fileUnwritable"));
+ $this->objMock->get->with(OPML::class)->returns($opml->get());
+ $this->assertConsole($cmd, $exitStatus);
+ $this->cli->loadConf->called();
+ $opml->exportFile->calledWith($file, $user, $flat);
}
public function provideOpmlExports(): iterable {
@@ -313,14 +359,14 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideOpmlImports */
public function testImportFromOpml(string $cmd, int $exitStatus, string $file, string $user, bool $flat, bool $replace): void {
- $opml = \Phake::mock(OPML::class);
- \Phake::when($opml)->importFile("php://input", $user, $flat, $replace)->thenReturn(true);
- \Phake::when($opml)->importFile("good.opml", $user, $flat, $replace)->thenReturn(true);
- \Phake::when($opml)->importFile("bad.opml", $user, $flat, $replace)->thenThrow(new \JKingWeb\Arsse\ImportExport\Exception("fileUnreadable"));
- \Phake::when($this->cli)->getInstance(OPML::class)->thenReturn($opml);
- $this->assertConsole($this->cli, $cmd, $exitStatus);
- \Phake::verify($this->cli)->loadConf;
- \Phake::verify($opml)->importFile($file, $user, $flat, $replace);
+ $opml = $this->mock(OPML::class);
+ $opml->importFile->with("php://input", $user, $flat, $replace)->returns(true);
+ $opml->importFile->with("good.opml", $user, $flat, $replace)->returns(true);
+ $opml->importFile->with("bad.opml", $user, $flat, $replace)->throws(new \JKingWeb\Arsse\ImportExport\Exception("fileUnreadable"));
+ $this->objMock->get->with(OPML::class)->returns($opml->get());
+ $this->assertConsole($cmd, $exitStatus);
+ $this->cli->loadConf->called();
+ $opml->importFile->calledWith($file, $user, $flat, $replace);
}
public function provideOpmlImports(): iterable {
@@ -359,4 +405,104 @@ class TestCLI extends \JKingWeb\Arsse\Test\AbstractTest {
["arsse.php import jane.doe@example.com bad.opml --replace --flat", 10603, "bad.opml", "jane.doe@example.com", true, true],
];
}
+
+ public function testShowMetadataOfAUser(): void {
+ $data = [
+ 'num' => 42,
+ 'admin' => false,
+ 'lang' => "en-ca",
+ 'tz' => "America/Toronto",
+ 'root_folder_name' => null,
+ 'sort_asc' => true,
+ 'theme' => null,
+ 'page_size' => 50,
+ 'shortcuts' => true,
+ 'gestures' => null,
+ 'reading_time' => false,
+ 'stylesheet' => "body {color:gray}",
+ ];
+ $exp = implode(\PHP_EOL, [
+ "num 42",
+ "admin false",
+ "lang 'en-ca'",
+ "tz 'America/Toronto'",
+ "root_folder_name NULL",
+ "sort_asc true",
+ "theme NULL",
+ "page_size 50",
+ "shortcuts true",
+ "gestures NULL",
+ "reading_time false",
+ "stylesheet 'body {color:gray}'",
+ ]);
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("propertiesGet")->willReturn($data);
+ Arsse::$user->expects($this->once())->method("propertiesGet")->with("john.doe@example.com", true);
+ $this->assertConsole("arsse.php user show john.doe@example.com", 0, $exp);
+ }
+
+ /** @dataProvider provideMetadataChanges */
+ public function testSetMetadataOfAUser(string $cmd, string $user, array $in, array $out, int $exp): void {
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("propertiesSet")->willReturn($out);
+ Arsse::$user->expects($this->once())->method("propertiesSet")->with($user, $in);
+ $this->assertConsole($cmd, $exp, "");
+ }
+
+ public function provideMetadataChanges(): iterable {
+ return [
+ ["arsse.php user set john admin true", "john", ['admin' => "true"], ['admin' => "true"], 0],
+ ["arsse.php user set john bogus 1", "john", ['bogus' => "1"], [], 1],
+ ["arsse.php user unset john admin", "john", ['admin' => null], ['admin' => null], 0],
+ ["arsse.php user unset john bogus", "john", ['bogus' => null], [], 1],
+ ];
+ }
+
+ public function testListTokens(): void {
+ $data = [
+ ['label' => 'Ook', 'id' => "TOKEN 1"],
+ ['label' => 'Eek', 'id' => "TOKEN 2"],
+ ['label' => null, 'id' => "TOKEN 3"],
+ ['label' => 'Ack', 'id' => "TOKEN 4"],
+ ];
+ $exp = implode(\PHP_EOL, [
+ "TOKEN 3 ",
+ "TOKEN 4 Ack",
+ "TOKEN 2 Eek",
+ "TOKEN 1 Ook",
+ ]);
+ $t = $this->mock(MinifluxToken::class);
+ $t->tokenList->returns($data);
+ $this->objMock->get->with(MinifluxToken::class)->returns($t->get());
+ $this->assertConsole("arsse.php token list john", 0, $exp);
+ $t->tokenList->calledWith("john");
+ }
+
+ public function testCreateToken(): void {
+ $t = $this->mock(MinifluxToken::class);
+ $t->tokenGenerate->returns("RANDOM TOKEN");
+ $this->objMock->get->with(MinifluxToken::class)->returns($t->get());
+ $this->assertConsole("arse.php token create jane", 0, "RANDOM TOKEN");
+ $t->tokenGenerate->calledWith("jane", null);
+ }
+
+ public function testCreateTokenWithLabel(): void {
+ $t = $this->mock(MinifluxToken::class);
+ $t->tokenGenerate->returns("RANDOM TOKEN");
+ $this->objMock->get->with(MinifluxToken::class)->returns($t->get());
+ $this->assertConsole("arse.php token create jane Ook", 0, "RANDOM TOKEN");
+ $t->tokenGenerate->calledWith("jane", "Ook");
+ }
+
+ public function testRevokeAToken(): void {
+ $this->dbMock->tokenRevoke->returns(true);
+ $this->assertConsole("arse.php token revoke jane TOKEN_ID", 0);
+ $this->dbMock->tokenRevoke->calledWith("jane", "miniflux.login", "TOKEN_ID");
+ }
+
+ public function testRevokeAllTokens(): void {
+ $this->dbMock->tokenRevoke->returns(true);
+ $this->assertConsole("arse.php token revoke jane", 0);
+ $this->dbMock->tokenRevoke->calledWith("jane", "miniflux.login", null);
+ }
}
diff --git a/tests/cases/Conf/TestConf.php b/tests/cases/Conf/TestConf.php
index e99eda3e..0e827d4b 100644
--- a/tests/cases/Conf/TestConf.php
+++ b/tests/cases/Conf/TestConf.php
@@ -15,7 +15,7 @@ class TestConf extends \JKingWeb\Arsse\Test\AbstractTest {
public static $path;
public function setUp(): void {
- self::clearData();
+ parent::setUp();
self::$vfs = vfsStream::setup("root", null, [
'confGood' => ' "xx");',
'confNotArray' => 'import($arr);
}
+ public function testImportCustomProperty(): void {
+ $arr = [
+ 'customProperty' => "I'm special!",
+ ];
+ $conf = new Conf;
+ $this->assertSame($conf, $conf->import($arr));
+ }
+
public function testImportBogusDriver(): void {
$arr = [
'dbDriver' => "this driver does not exist",
diff --git a/tests/cases/Database/AbstractTest.php b/tests/cases/Database/AbstractTest.php
index ff5acdd5..ef1f0d7b 100644
--- a/tests/cases/Database/AbstractTest.php
+++ b/tests/cases/Database/AbstractTest.php
@@ -18,6 +18,7 @@ abstract class AbstractTest extends \JKingWeb\Arsse\Test\AbstractTest {
use SeriesToken;
use SeriesFolder;
use SeriesFeed;
+ use SeriesIcon;
use SeriesSubscription;
use SeriesLabel;
use SeriesTag;
@@ -72,8 +73,8 @@ abstract class AbstractTest extends \JKingWeb\Arsse\Test\AbstractTest {
Arsse::$db = new Database(static::$drv);
Arsse::$db->driverSchemaUpdate();
// create a mock user manager
- Arsse::$user = \Phake::mock(User::class);
- \Phake::when(Arsse::$user)->authorize->thenReturn(true);
+ $this->userMock = $this->mock(User::class);
+ Arsse::$user = $this->userMock->get();
// call the series-specific setup method
$setUp = "setUp".$this->series;
$this->$setUp();
diff --git a/tests/cases/Database/SeriesArticle.php b/tests/cases/Database/SeriesArticle.php
index 2f78e9c1..eace73af 100644
--- a/tests/cases/Database/SeriesArticle.php
+++ b/tests/cases/Database/SeriesArticle.php
@@ -19,12 +19,14 @@ trait SeriesArticle {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
- ["john.doe@example.org", ""],
- ["john.doe@example.net", ""],
+ ["jane.doe@example.com", "", 1],
+ ["john.doe@example.com", "", 2],
+ ["john.doe@example.org", "", 3],
+ ["john.doe@example.net", "", 4],
+ ["jill.doe@example.com", "", 5],
],
],
'arsse_feeds' => [
@@ -92,22 +94,24 @@ trait SeriesArticle {
'feed' => "int",
'folder' => "int",
'title' => "str",
+ 'scrape' => "bool",
],
'rows' => [
- [1, "john.doe@example.com",1, null,"Subscription 1"],
- [2, "john.doe@example.com",2, null,null],
- [3, "john.doe@example.com",3, 1,"Subscription 3"],
- [4, "john.doe@example.com",4, 6,null],
- [5, "john.doe@example.com",10, 5,"Subscription 5"],
- [6, "jane.doe@example.com",1, null,null],
- [7, "jane.doe@example.com",10,null,"Subscription 7"],
- [8, "john.doe@example.org",11,null,null],
- [9, "john.doe@example.org",12,null,"Subscription 9"],
- [10,"john.doe@example.org",13,null,null],
- [11,"john.doe@example.net",10,null,"Subscription 11"],
- [12,"john.doe@example.net",2, 9,null],
- [13,"john.doe@example.net",3, 8,"Subscription 13"],
- [14,"john.doe@example.net",4, 7,null],
+ [1, "john.doe@example.com",1, null,"Subscription 1",0],
+ [2, "john.doe@example.com",2, null,null,0],
+ [3, "john.doe@example.com",3, 1,"Subscription 3",0],
+ [4, "john.doe@example.com",4, 6,null,0],
+ [5, "john.doe@example.com",10, 5,"Subscription 5",0],
+ [6, "jane.doe@example.com",1, null,null,0],
+ [7, "jane.doe@example.com",10,null,"Subscription 7",0],
+ [8, "john.doe@example.org",11,null,null,0],
+ [9, "john.doe@example.org",12,null,"Subscription 9",0],
+ [10,"john.doe@example.org",13,null,null,0],
+ [11,"john.doe@example.net",10,null,"Subscription 11",0],
+ [12,"john.doe@example.net",2, 9,null,0],
+ [13,"john.doe@example.net",3, 8,"Subscription 13",0],
+ [14,"john.doe@example.net",4, 7,null,0],
+ [15,"jill.doe@example.com",11,null,null,1],
],
],
'arsse_tag_members' => [
@@ -144,33 +148,34 @@ trait SeriesArticle {
'url_content_hash' => "str",
'title_content_hash' => "str",
'modified' => "datetime",
+ 'content_scraped' => "str",
],
'rows' => [
- [1,1,null,"Title one", null,null,null,"First article", null,"","","","2000-01-01T00:00:00Z"],
- [2,1,null,"Title two", null,null,null,"Second article",null,"","","","2010-01-01T00:00:00Z"],
- [3,2,null,"Title three",null,null,null,"Third article", null,"","","","2000-01-01T00:00:00Z"],
- [4,2,null,null,"John Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z"],
- [5,3,null,null,"John Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z"],
- [6,3,null,null,"Jane Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z"],
- [7,4,null,null,"Jane Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z"],
- [8,4,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
- [9,5,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
- [10,5,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
- [11,6,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
- [12,6,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
- [13,7,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
- [14,7,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
- [15,8,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
- [16,8,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
- [17,9,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
- [18,9,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
- [19,10,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z"],
- [20,10,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z"],
- [101,11,'http://example.com/1','Article title 1','','2000-01-01 00:00:00','2000-01-01 00:00:01','Article content 1
','e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda','f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6','fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4','18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207','2000-01-01 01:00:00'],
- [102,11,'http://example.com/2','Article title 2','','2000-01-02 00:00:00','2000-01-02 00:00:02','Article content 2
','5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7','0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153','13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9','2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e','2000-01-02 02:00:00'],
- [103,12,'http://example.com/3','Article title 3','','2000-01-03 00:00:00','2000-01-03 00:00:03','Article content 3
','31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92','f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b','b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406','ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b','2000-01-03 03:00:00'],
- [104,12,'http://example.com/4','Article title 4','','2000-01-04 00:00:00','2000-01-04 00:00:04','Article content 4
','804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180','f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8','f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3','ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9','2000-01-04 04:00:00'],
- [105,13,'http://example.com/5','Article title 5','','2000-01-05 00:00:00','2000-01-05 00:00:05','Article content 5
','db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41','d40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022','834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900','43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba','2000-01-05 05:00:00'],
+ [1,1,null,"Title one", null,null,null,"First article", null,"","","","2000-01-01T00:00:00Z",null],
+ [2,1,null,"Title two", null,null,null,"Second article",null,"","","","2010-01-01T00:00:00Z",null],
+ [3,2,null,"Title three",null,null,null,"Third article", null,"","","","2000-01-01T00:00:00Z",null],
+ [4,2,null,null,"John Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
+ [5,3,null,null,"John Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
+ [6,3,null,null,"Jane Doe",null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
+ [7,4,null,null,"Jane Doe",null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
+ [8,4,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
+ [9,5,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
+ [10,5,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
+ [11,6,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
+ [12,6,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
+ [13,7,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
+ [14,7,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
+ [15,8,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
+ [16,8,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
+ [17,9,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
+ [18,9,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
+ [19,10,null,null,null,null,null,null,null,"","","","2000-01-01T00:00:00Z",null],
+ [20,10,null,null,null,null,null,null,null,"","","","2010-01-01T00:00:00Z",null],
+ [101,11,'http://example.com/1','Article title 1','','2000-01-01 00:00:00','2000-01-01 00:00:01','Article content 1
','e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda','f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6','fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4','18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207','2000-01-01 01:00:00',"Scraped content 1
"],
+ [102,11,'http://example.com/2','Article title 2','','2000-01-02 00:00:00','2000-01-02 00:00:02','Article content 2
','5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7','0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153','13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9','2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e','2000-01-02 02:00:00',null],
+ [103,12,'http://example.com/3','Article title 3','','2000-01-03 00:00:00','2000-01-03 00:00:03','Article content 3
','31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92','f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b','b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406','ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b','2000-01-03 03:00:00',null],
+ [104,12,'http://example.com/4','Article title 4','','2000-01-04 00:00:00','2000-01-04 00:00:04','Article content 4
','804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180','f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8','f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3','ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9','2000-01-04 04:00:00',null],
+ [105,13,'http://example.com/5','Article title 5','','2000-01-05 00:00:00','2000-01-05 00:00:05','Article content 5
','db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41','d40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022','834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900','43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba','2000-01-05 05:00:00',null],
],
],
'arsse_enclosures' => [
@@ -234,21 +239,25 @@ trait SeriesArticle {
'starred' => "bool",
'modified' => "datetime",
'note' => "str",
+ 'hidden' => "bool",
],
'rows' => [
- [1, 1,1,1,'2000-01-01 00:00:00',''],
- [5, 19,1,0,'2016-01-01 00:00:00',''],
- [5, 20,0,1,'2005-01-01 00:00:00',''],
- [7, 20,1,0,'2010-01-01 00:00:00',''],
- [8, 102,1,0,'2000-01-02 02:00:00','Note 2'],
- [9, 103,0,1,'2000-01-03 03:00:00','Note 3'],
- [9, 104,1,1,'2000-01-04 04:00:00','Note 4'],
- [10,105,0,0,'2000-01-05 05:00:00',''],
- [11, 19,0,0,'2017-01-01 00:00:00','ook'],
- [11, 20,1,0,'2017-01-01 00:00:00','eek'],
- [12, 3,0,1,'2017-01-01 00:00:00','ack'],
- [12, 4,1,1,'2017-01-01 00:00:00','ach'],
- [1, 2,0,0,'2010-01-01 00:00:00','Some Note'],
+ [1, 1,1,1,'2000-01-01 00:00:00','',0],
+ [5, 19,1,0,'2016-01-01 00:00:00','',0],
+ [5, 20,0,1,'2005-01-01 00:00:00','',0],
+ [7, 20,1,0,'2010-01-01 00:00:00','',0],
+ [8, 102,1,0,'2000-01-02 02:00:00','Note 2',0],
+ [9, 103,0,1,'2000-01-03 03:00:00','Note 3',0],
+ [9, 104,1,1,'2000-01-04 04:00:00','Note 4',0],
+ [10,105,0,0,'2000-01-05 05:00:00','',0],
+ [11, 19,0,0,'2017-01-01 00:00:00','ook',0],
+ [11, 20,1,0,'2017-01-01 00:00:00','eek',0],
+ [12, 3,0,1,'2017-01-01 00:00:00','ack',0],
+ [12, 4,1,1,'2017-01-01 00:00:00','ach',0],
+ [1, 2,0,0,'2010-01-01 00:00:00','Some Note',0],
+ [3, 6,0,0,'2000-01-01 00:00:00','',1],
+ [6, 1,0,1,'2010-01-01 00:00:00','',1],
+ [6, 2,1,0,'2010-01-01 00:00:00','',1],
],
],
'arsse_categories' => [ // author-supplied categories
@@ -399,12 +408,13 @@ trait SeriesArticle {
],
];
$this->fields = [
- "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "edition", "edited_date",
+ "id", "subscription", "feed", "modified_date", "marked_date", "unread", "starred", "hidden", "edition", "edited_date",
"url", "title", "subscription_title", "author", "guid", "published_date", "fingerprint",
+ "folder", "top_folder", "folder_name", "top_folder_name",
"content", "media_url", "media_type",
"note",
];
- $this->checkTables = ['arsse_marks' => ["subscription","article","read","starred","modified","note"]];
+ $this->checkTables = ['arsse_marks' => ["subscription", "article", "read", "starred", "modified", "note", "hidden"]];
$this->user = "john.doe@example.net";
}
@@ -442,6 +452,8 @@ trait SeriesArticle {
'Starred and Read in subscription' => [(new Context)->starred(true)->unread(false)->subscription(5), []],
'Annotated' => [(new Context)->annotated(true), [2]],
'Not annotated' => [(new Context)->annotated(false), [1,3,4,5,6,7,8,19,20]],
+ 'Hidden' => [(new Context)->hidden(true), [6]],
+ 'Not hidden' => [(new Context)->hidden(false), [1,2,3,4,5,7,8,19,20]],
'Labelled' => [(new Context)->labelled(true), [1,5,8,19,20]],
'Not labelled' => [(new Context)->labelled(false), [2,3,4,6,7]],
'Not after edition 999' => [(new Context)->subscription(5)->latestEdition(999), [19]],
@@ -596,12 +608,6 @@ trait SeriesArticle {
];
}
- public function testListArticlesWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->articleList($this->user);
- }
-
public function testMarkNothing(): void {
$this->assertSame(0, Arsse::$db->articleMark($this->user, []));
}
@@ -625,10 +631,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,'',0];
+ $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,7,1,0,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,8,1,0,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -651,10 +657,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,'',0];
+ $state['arsse_marks']['rows'][] = [13,6,0,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,8,0,1,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -683,10 +689,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,'',0];
+ $state['arsse_marks']['rows'][] = [13,6,1,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,7,1,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,8,1,1,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -701,10 +707,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,'',0];
+ $state['arsse_marks']['rows'][] = [13,6,0,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,8,0,1,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -719,10 +725,10 @@ 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,'',0];
+ $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,7,1,0,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,8,1,0,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -738,10 +744,10 @@ trait SeriesArticle {
$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'];
+ $state['arsse_marks']['rows'][] = [13,5,0,0,$now,'New note',0];
+ $state['arsse_marks']['rows'][] = [13,6,0,0,$now,'New note',0];
+ $state['arsse_marks']['rows'][] = [14,7,0,0,$now,'New note',0];
+ $state['arsse_marks']['rows'][] = [14,8,0,0,$now,'New note',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -749,10 +755,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,'',0];
+ $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,7,1,0,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,8,1,0,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -760,8 +766,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,'',0];
+ $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -774,8 +780,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,'',0];
+ $state['arsse_marks']['rows'][] = [13,6,1,0,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -799,7 +805,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,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -812,7 +818,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,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -841,7 +847,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,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -879,7 +885,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,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -939,10 +945,10 @@ 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,'',0];
+ $state['arsse_marks']['rows'][] = [13,6,0,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
+ $state['arsse_marks']['rows'][] = [14,8,0,1,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
@@ -961,31 +967,19 @@ trait SeriesArticle {
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,'',0];
+ $state['arsse_marks']['rows'][] = [14,7,0,1,$now,'',0];
$this->compareExpectations(static::$drv, $state);
}
- public function testMarkArticlesWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->articleMark($this->user, ['read' => false]);
- }
-
public function testCountArticles(): void {
$setSize = (new \ReflectionClassConstant(Database::class, "LIMIT_SET_SIZE"))->getValue();
$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(1, 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, $setSize * 3))));
}
- public function testCountArticlesWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->articleCount($this->user);
- }
-
public function testFetchStarredCounts(): void {
$exp1 = ['total' => 2, 'unread' => 1, 'read' => 1];
$exp2 = ['total' => 0, 'unread' => 0, 'read' => 0];
@@ -993,15 +987,10 @@ trait SeriesArticle {
$this->assertEquals($exp2, Arsse::$db->articleStarred("jane.doe@example.com"));
}
- public function testFetchStarredCountsWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->articleStarred($this->user);
- }
-
public function testFetchLatestEdition(): void {
$this->assertSame(1001, Arsse::$db->editionLatest($this->user));
$this->assertSame(4, Arsse::$db->editionLatest($this->user, (new Context)->subscription(12)));
+ $this->assertSame(5, Arsse::$db->editionLatest("john.doe@example.com", (new Context)->subscription(3)->hidden(false)));
}
public function testFetchLatestEditionOfMissingSubscription(): void {
@@ -1009,12 +998,6 @@ trait SeriesArticle {
Arsse::$db->editionLatest($this->user, (new Context)->subscription(1));
}
- public function testFetchLatestEditionWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->editionLatest($this->user);
- }
-
public function testListTheLabelsOfAnArticle(): void {
$this->assertEquals([1,2], Arsse::$db->articleLabelsGet("john.doe@example.com", 1));
$this->assertEquals([2], Arsse::$db->articleLabelsGet("john.doe@example.com", 5));
@@ -1029,12 +1012,6 @@ trait SeriesArticle {
Arsse::$db->articleLabelsGet($this->user, 101);
}
- public function testListTheLabelsOfAnArticleWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->articleLabelsGet("john.doe@example.com", 1);
- }
-
public function testListTheCategoriesOfAnArticle(): void {
$exp = ["Fascinating", "Logical"];
$this->assertSame($exp, Arsse::$db->articleCategoriesGet($this->user, 19));
@@ -1049,12 +1026,6 @@ trait SeriesArticle {
Arsse::$db->articleCategoriesGet($this->user, 101);
}
- public function testListTheCategoriesOfAnArticleWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->articleCategoriesGet($this->user, 19);
- }
-
/** @dataProvider provideArrayContextOptions */
public function testUseTooFewValuesInArrayContext(string $option): void {
$this->assertException("tooShort", "Db", "ExceptionInput");
@@ -1071,4 +1042,139 @@ trait SeriesArticle {
yield [$method];
}
}
+
+ public function testMarkAllArticlesNotHidden(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['hidden' => false]);
+ $now = Date::transform(time(), "sql");
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $state['arsse_marks']['rows'][14][6] = 0;
+ $state['arsse_marks']['rows'][14][4] = $now;
+ $state['arsse_marks']['rows'][15][6] = 0;
+ $state['arsse_marks']['rows'][15][4] = $now;
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testMarkAllArticlesHidden(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['hidden' => true]);
+ $now = Date::transform(time(), "sql");
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $state['arsse_marks']['rows'][3][6] = 1;
+ $state['arsse_marks']['rows'][3][4] = $now;
+ $state['arsse_marks']['rows'][] = [7,19,0,0,$now,'',1];
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testMarkAllArticlesUnreadAndNotHidden(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['read' => false, 'hidden' => false]);
+ $now = Date::transform(time(), "sql");
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $state['arsse_marks']['rows'][3][2] = 0;
+ $state['arsse_marks']['rows'][3][4] = $now;
+ $state['arsse_marks']['rows'][14][6] = 0;
+ $state['arsse_marks']['rows'][14][4] = $now;
+ $state['arsse_marks']['rows'][15][2] = 0;
+ $state['arsse_marks']['rows'][15][6] = 0;
+ $state['arsse_marks']['rows'][15][4] = $now;
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testMarkAllArticlesReadAndHidden(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['read' => true, 'hidden' => true]);
+ $now = Date::transform(time(), "sql");
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $state['arsse_marks']['rows'][3][6] = 1;
+ $state['arsse_marks']['rows'][3][4] = $now;
+ $state['arsse_marks']['rows'][14][2] = 1;
+ $state['arsse_marks']['rows'][14][4] = $now;
+ $state['arsse_marks']['rows'][] = [7,19,1,0,$now,'',1];
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testMarkAllArticlesUnreadAndHidden(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['read' => false,'hidden' => true]);
+ $now = Date::transform(time(), "sql");
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $state['arsse_marks']['rows'][3][2] = 0;
+ $state['arsse_marks']['rows'][3][6] = 1;
+ $state['arsse_marks']['rows'][3][4] = $now;
+ $state['arsse_marks']['rows'][15][2] = 0;
+ $state['arsse_marks']['rows'][15][4] = $now;
+ $state['arsse_marks']['rows'][] = [7,19,0,0,$now,'',1];
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testMarkAllArticlesReadAndNotHidden(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['read' => true,'hidden' => false]);
+ $now = Date::transform(time(), "sql");
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $state['arsse_marks']['rows'][14][2] = 1;
+ $state['arsse_marks']['rows'][14][6] = 0;
+ $state['arsse_marks']['rows'][14][4] = $now;
+ $state['arsse_marks']['rows'][15][6] = 0;
+ $state['arsse_marks']['rows'][15][4] = $now;
+ $state['arsse_marks']['rows'][] = [7,19,1,0,$now,'',0];
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testMarkMultipleEditionsUnreadAndHiddenWithStale(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['read' => false,'hidden' => true], (new Context)->editions([1,2,19,20]));
+ $now = Date::transform(time(), "sql");
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $state['arsse_marks']['rows'][3][6] = 1;
+ $state['arsse_marks']['rows'][3][4] = $now;
+ $state['arsse_marks']['rows'][15][2] = 0;
+ $state['arsse_marks']['rows'][15][6] = 1;
+ $state['arsse_marks']['rows'][15][4] = $now;
+ $state['arsse_marks']['rows'][] = [7,19,0,0,$now,'',1];
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testMarkAStaleEditionHidden(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['hidden' => true], (new Context)->edition(20));
+ $now = Date::transform(time(), "sql");
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $state['arsse_marks']['rows'][3][6] = 1;
+ $state['arsse_marks']['rows'][3][4] = $now;
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testMarkAStaleEditionUnreadAndHidden(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['read' => false,'hidden' => true], (new Context)->edition(20)); // only starred is changed
+ $now = Date::transform(time(), "sql");
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $state['arsse_marks']['rows'][3][6] = 1;
+ $state['arsse_marks']['rows'][3][4] = $now;
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testMarkAStaleEditionUnreadAndNotHidden(): void {
+ Arsse::$db->articleMark("jane.doe@example.com", ['read' => false,'hidden' => false], (new Context)->edition(20)); // no changes occur
+ $state = $this->primeExpectations($this->data, $this->checkTables);
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testSelectScrapedContent(): void {
+ $exp = [
+ ['id' => 101, 'content' => "Article content 1
"],
+ ['id' => 102, 'content' => "Article content 2
"],
+ ];
+ $this->assertResult($exp, Arsse::$db->articleList("john.doe@example.org", (new Context)->subscription(8), ["id", "content"]));
+ $exp = [
+ ['id' => 101, 'content' => "Scraped content 1
"],
+ ['id' => 102, 'content' => "Article content 2
"],
+ ];
+ $this->assertResult($exp, Arsse::$db->articleList("jill.doe@example.com", (new Context)->subscription(15), ["id", "content"]));
+ }
+
+ public function testSearchScrapedContent(): void {
+ $exp = [
+ ['id' => 101, 'content' => "Scraped content 1
"],
+ ['id' => 102, 'content' => "Article content 2
"],
+ ];
+ $this->assertResult($exp, Arsse::$db->articleList("jill.doe@example.com", (new Context)->subscription(15)->searchTerms(["article"]), ["id", "content"]));
+ $exp = [
+ ['id' => 101, 'content' => "Scraped content 1
"],
+ ];
+ $this->assertResult($exp, Arsse::$db->articleList("jill.doe@example.com", (new Context)->subscription(15)->searchTerms(["scraped"]), ["id", "content"]));
+ }
}
diff --git a/tests/cases/Database/SeriesCleanup.php b/tests/cases/Database/SeriesCleanup.php
index ad40dcb3..d863a644 100644
--- a/tests/cases/Database/SeriesCleanup.php
+++ b/tests/cases/Database/SeriesCleanup.php
@@ -30,10 +30,11 @@ trait SeriesCleanup {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
+ ["jane.doe@example.com", "",1],
+ ["john.doe@example.com", "",2],
],
],
'arsse_sessions' => [
@@ -65,6 +66,18 @@ trait SeriesCleanup {
["da772f8fa13c11e78667001e673b2560", "class.class", "john.doe@example.com", $soon],
],
],
+ 'arsse_icons' => [
+ 'columns' => [
+ 'id' => "int",
+ 'url' => "str",
+ 'orphaned' => "datetime",
+ ],
+ 'rows' => [
+ [1,'http://localhost:8000/Icon/PNG',$daybefore],
+ [2,'http://localhost:8000/Icon/GIF',$daybefore],
+ [3,'http://localhost:8000/Icon/SVG1',null],
+ ],
+ ],
'arsse_feeds' => [
'columns' => [
'id' => "int",
@@ -72,12 +85,13 @@ trait SeriesCleanup {
'title' => "str",
'orphaned' => "datetime",
'size' => "int",
+ 'icon' => "int",
],
'rows' => [
- [1,"http://example.com/1","",$daybefore,2], //latest two articles should be kept
- [2,"http://example.com/2","",$yesterday,0],
- [3,"http://example.com/3","",null,0],
- [4,"http://example.com/4","",$nowish,0],
+ [1,"http://example.com/1","",$daybefore,2,null], //latest two articles should be kept
+ [2,"http://example.com/2","",$yesterday,0,2],
+ [3,"http://example.com/3","",null,0,1],
+ [4,"http://example.com/4","",$nowish,0,null],
],
],
'arsse_subscriptions' => [
@@ -134,16 +148,18 @@ trait SeriesCleanup {
'subscription' => "int",
'read' => "bool",
'starred' => "bool",
+ 'hidden' => "bool",
'modified' => "datetime",
],
'rows' => [
- [3,1,0,1,$weeksago],
- [4,1,1,0,$daysago],
- [6,1,1,0,$nowish],
- [6,2,1,0,$weeksago],
- [8,1,1,0,$weeksago],
- [9,1,1,0,$daysago],
- [9,2,1,0,$daysago],
+ [3,1,0,1,0,$weeksago],
+ [4,1,1,0,0,$daysago],
+ [6,1,1,0,0,$nowish],
+ [6,2,1,0,0,$weeksago],
+ [7,2,0,1,1,$weeksago], // hidden takes precedence over starred
+ [8,1,1,0,0,$weeksago],
+ [9,1,1,0,0,$daysago],
+ [9,2,0,0,1,$daysago], // hidden is the same as read for the purposes of cleanup
],
],
];
@@ -179,6 +195,32 @@ trait SeriesCleanup {
$this->compareExpectations(static::$drv, $state);
}
+ public function testCleanUpOrphanedIcons(): void {
+ Arsse::$db->iconCleanup();
+ $now = gmdate("Y-m-d H:i:s");
+ $state = $this->primeExpectations($this->data, [
+ 'arsse_icons' => ["id","orphaned"],
+ ]);
+ $state['arsse_icons']['rows'][0][1] = null;
+ unset($state['arsse_icons']['rows'][1]);
+ $state['arsse_icons']['rows'][2][1] = $now;
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testCleanUpOrphanedIconsWithUnlimitedRetention(): void {
+ Arsse::$conf->import([
+ 'purgeFeeds' => null,
+ ]);
+ Arsse::$db->iconCleanup();
+ $now = gmdate("Y-m-d H:i:s");
+ $state = $this->primeExpectations($this->data, [
+ 'arsse_icons' => ["id","orphaned"],
+ ]);
+ $state['arsse_icons']['rows'][0][1] = null;
+ $state['arsse_icons']['rows'][2][1] = $now;
+ $this->compareExpectations(static::$drv, $state);
+ }
+
public function testCleanUpOldArticlesWithStandardRetention(): void {
Arsse::$db->articleCleanup();
$state = $this->primeExpectations($this->data, [
diff --git a/tests/cases/Database/SeriesFeed.php b/tests/cases/Database/SeriesFeed.php
index d4a75213..5cc0d84c 100644
--- a/tests/cases/Database/SeriesFeed.php
+++ b/tests/cases/Database/SeriesFeed.php
@@ -7,6 +7,7 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\Database;
use JKingWeb\Arsse\Arsse;
+use JKingWeb\Arsse\Test\Result;
trait SeriesFeed {
protected function setUpSeriesFeed(): void {
@@ -19,10 +20,25 @@ trait SeriesFeed {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
+ ["jane.doe@example.com", "",1],
+ ["john.doe@example.com", "",2],
+ ],
+ ],
+ 'arsse_icons' => [
+ 'columns' => [
+ 'id' => "int",
+ 'url' => "str",
+ 'type' => "str",
+ 'data' => "blob",
+ ],
+ 'rows' => [
+ [1,'http://localhost:8000/Icon/PNG','image/png',base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMjHxIGmVAAAADUlEQVQYV2NgYGBgAAAABQABijPjAAAAAABJRU5ErkJggg==")],
+ [2,'http://localhost:8000/Icon/GIF','image/gif',base64_decode("R0lGODlhAQABAIABAAAAAP///yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==")],
+ // this actually contains the data of SVG2, which will lead to a row update when retieved
+ [3,'http://localhost:8000/Icon/SVG1','image/svg+xml',' '],
],
],
'arsse_feeds' => [
@@ -35,28 +51,36 @@ trait SeriesFeed {
'modified' => "datetime",
'next_fetch' => "datetime",
'size' => "int",
+ 'icon' => "int",
],
'rows' => [
- [1,"http://localhost:8000/Feed/Matching/3","Ook",0,"",$past,$past,0],
- [2,"http://localhost:8000/Feed/Matching/1","Eek",5,"There was an error last time",$past,$future,0],
- [3,"http://localhost:8000/Feed/Fetching/Error?code=404","Ack",0,"",$past,$now,0],
- [4,"http://localhost:8000/Feed/NextFetch/NotModified?t=".time(),"Ooook",0,"",$past,$past,0],
- [5,"http://localhost:8000/Feed/Parsing/Valid","Ooook",0,"",$past,$future,0],
+ [1,"http://localhost:8000/Feed/Matching/3","Ook",0,"",$past,$past,0,null],
+ [2,"http://localhost:8000/Feed/Matching/1","Eek",5,"There was an error last time",$past,$future,0,null],
+ [3,"http://localhost:8000/Feed/Fetching/Error?code=404","Ack",0,"",$past,$now,0,null],
+ [4,"http://localhost:8000/Feed/NextFetch/NotModified?t=".time(),"Ooook",0,"",$past,$past,0,null],
+ [5,"http://localhost:8000/Feed/Parsing/Valid","Ooook",0,"",$past,$future,0,null],
+ // these feeds all test icon caching
+ [6,"http://localhost:8000/Feed/WithIcon/PNG",null,0,"",$past,$future,0,1], // no change when updated
+ [7,"http://localhost:8000/Feed/WithIcon/GIF",null,0,"",$past,$future,0,1], // icon ID 2 will be assigned to feed when updated
+ [8,"http://localhost:8000/Feed/WithIcon/SVG1",null,0,"",$past,$future,0,3], // icon ID 3 will be modified when updated
+ [9,"http://localhost:8000/Feed/WithIcon/SVG2",null,0,"",$past,$future,0,null], // icon ID 4 will be created and assigned to feed when updated
],
],
'arsse_subscriptions' => [
'columns' => [
- 'id' => "int",
- 'owner' => "str",
- 'feed' => "int",
+ 'id' => "int",
+ 'owner' => "str",
+ 'feed' => "int",
+ 'keep_rule' => "str",
+ 'block_rule' => "str",
],
'rows' => [
- [1,'john.doe@example.com',1],
- [2,'john.doe@example.com',2],
- [3,'john.doe@example.com',3],
- [4,'john.doe@example.com',4],
- [5,'john.doe@example.com',5],
- [6,'jane.doe@example.com',1],
+ [1,'john.doe@example.com',1,null,'^Sport$'],
+ [2,'john.doe@example.com',2,"",null],
+ [3,'john.doe@example.com',3,'\w+',null],
+ [4,'john.doe@example.com',4,'\w+',"["], // invalid rule leads to both rules being ignored
+ [5,'john.doe@example.com',5,null,'and/or'],
+ [6,'jane.doe@example.com',1,'^(?i)[a-z]+','3|6'],
],
],
'arsse_articles' => [
@@ -105,19 +129,20 @@ trait SeriesFeed {
'subscription' => "int",
'read' => "bool",
'starred' => "bool",
+ 'hidden' => "bool",
'modified' => "datetime",
],
'rows' => [
// Jane's marks
- [1,6,1,0,$past],
- [2,6,1,0,$past],
- [3,6,1,1,$past],
- [4,6,1,0,$past],
- [5,6,1,1,$past],
+ [1,6,1,0,0,$past],
+ [2,6,1,0,0,$past],
+ [3,6,1,1,0,$past],
+ [4,6,1,0,1,$past],
+ [5,6,1,1,0,$past],
// John's marks
- [1,1,1,0,$past],
- [3,1,1,0,$past],
- [4,1,0,1,$past],
+ [1,1,1,0,0,$past],
+ [3,1,1,0,0,$past],
+ [4,1,0,1,0,$past],
],
],
'arsse_enclosures' => [
@@ -179,6 +204,21 @@ trait SeriesFeed {
$this->assertResult([['id' => 1]], Arsse::$db->feedMatchIds(1, ['e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda'])); // this ID appears in both feed 1 and feed 2; only one result should be returned
}
+ /** @dataProvider provideFilterRules */
+ public function testGetRules(int $in, array $exp): void {
+ $this->assertSame($exp, Arsse::$db->feedRulesGet($in));
+ }
+
+ public function provideFilterRules(): iterable {
+ return [
+ [1, ['jane.doe@example.com' => ['keep' => "`^(?i)[a-z]+`u", 'block' => "`3|6`u"], 'john.doe@example.com' => ['keep' => "", 'block' => "`^Sport$`u"]]],
+ [2, []],
+ [3, ['john.doe@example.com' => ['keep' => '`\w+`u', 'block' => ""]]],
+ [4, []],
+ [5, ['john.doe@example.com' => ['keep' => "", 'block' => "`and/or`u"]]],
+ ];
+ }
+
public function testUpdateAFeed(): void {
// update a valid feed with both new and changed items
Arsse::$db->feedUpdate(1);
@@ -186,7 +226,7 @@ trait SeriesFeed {
$state = $this->primeExpectations($this->data, [
'arsse_articles' => ["id", "feed","url","title","author","published","edited","content","guid","url_title_hash","url_content_hash","title_content_hash","modified"],
'arsse_editions' => ["id","article","modified"],
- 'arsse_marks' => ["subscription","article","read","starred","modified"],
+ 'arsse_marks' => ["subscription","article","read","starred","hidden","modified"],
'arsse_feeds' => ["id","size"],
]);
$state['arsse_articles']['rows'][2] = [3,1,'http://example.com/3','Article title 3 (updated)','','2000-01-03 00:00:00','2000-01-03 00:00:00','Article content 3
','31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92','6cc99be662ef3486fef35a890123f18d74c29a32d714802d743c5b4ef713315a','b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406','d5faccc13bf8267850a1e8e61f95950a0f34167df2c8c58011c0aaa6367026ac',$now];
@@ -197,9 +237,10 @@ trait SeriesFeed {
[7,3,$now],
[8,4,$now],
]);
- $state['arsse_marks']['rows'][2] = [6,3,0,1,$now];
- $state['arsse_marks']['rows'][3] = [6,4,0,0,$now];
- $state['arsse_marks']['rows'][6] = [1,3,0,0,$now];
+ $state['arsse_marks']['rows'][2] = [6,3,0,1,1,$now];
+ $state['arsse_marks']['rows'][3] = [6,4,0,0,0,$now];
+ $state['arsse_marks']['rows'][6] = [1,3,0,0,0,$now];
+ $state['arsse_marks']['rows'][] = [6,8,0,0,1,null];
$state['arsse_feeds']['rows'][0] = [1,6];
$this->compareExpectations(static::$drv, $state);
// update a valid feed which previously had an error
@@ -260,4 +301,44 @@ trait SeriesFeed {
Arsse::$db->feedUpdate(4);
$this->assertEquals([1], Arsse::$db->feedListStale());
}
+
+ public function testCheckIconDuringFeedUpdate(): void {
+ Arsse::$db->feedUpdate(6);
+ $state = $this->primeExpectations($this->data, [
+ 'arsse_icons' => ["id","url","type","data"],
+ 'arsse_feeds' => ["id", "icon"],
+ ]);
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testAssignIconDuringFeedUpdate(): void {
+ Arsse::$db->feedUpdate(7);
+ $state = $this->primeExpectations($this->data, [
+ 'arsse_icons' => ["id","url","type","data"],
+ 'arsse_feeds' => ["id", "icon"],
+ ]);
+ $state['arsse_feeds']['rows'][6][1] = 2;
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testChangeIconDuringFeedUpdate(): void {
+ Arsse::$db->feedUpdate(8);
+ $state = $this->primeExpectations($this->data, [
+ 'arsse_icons' => ["id","url","type","data"],
+ 'arsse_feeds' => ["id", "icon"],
+ ]);
+ $state['arsse_icons']['rows'][2][3] = ' ';
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testAddIconDuringFeedUpdate(): void {
+ Arsse::$db->feedUpdate(9);
+ $state = $this->primeExpectations($this->data, [
+ 'arsse_icons' => ["id","url","type","data"],
+ 'arsse_feeds' => ["id", "icon"],
+ ]);
+ $state['arsse_feeds']['rows'][8][1] = 4;
+ $state['arsse_icons']['rows'][] = [4,'http://localhost:8000/Icon/SVG2','image/svg+xml',' '];
+ $this->compareExpectations(static::$drv, $state);
+ }
}
diff --git a/tests/cases/Database/SeriesFolder.php b/tests/cases/Database/SeriesFolder.php
index 6d69f64a..4c488ced 100644
--- a/tests/cases/Database/SeriesFolder.php
+++ b/tests/cases/Database/SeriesFolder.php
@@ -15,10 +15,11 @@ trait SeriesFolder {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
+ ["jane.doe@example.com", "",1],
+ ["john.doe@example.com", "",2],
],
],
'arsse_folders' => [
@@ -101,7 +102,6 @@ trait SeriesFolder {
$user = "john.doe@example.com";
$folderID = $this->nextID("arsse_folders");
$this->assertSame($folderID, Arsse::$db->folderAdd($user, ['name' => "Entertainment"]));
- \Phake::verify(Arsse::$user)->authorize($user, "folderAdd");
$state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]);
$state['arsse_folders']['rows'][] = [$folderID, $user, null, "Entertainment"];
$this->compareExpectations(static::$drv, $state);
@@ -116,7 +116,6 @@ trait SeriesFolder {
$user = "john.doe@example.com";
$folderID = $this->nextID("arsse_folders");
$this->assertSame($folderID, Arsse::$db->folderAdd($user, ['name' => "GNOME", 'parent' => 2]));
- \Phake::verify(Arsse::$user)->authorize($user, "folderAdd");
$state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]);
$state['arsse_folders']['rows'][] = [$folderID, $user, 2, "GNOME"];
$this->compareExpectations(static::$drv, $state);
@@ -152,12 +151,6 @@ trait SeriesFolder {
Arsse::$db->folderAdd("john.doe@example.com", ['name' => " "]);
}
- public function testAddAFolderWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->folderAdd("john.doe@example.com", ['name' => "Sociology"]);
- }
-
public function testListRootFolders(): void {
$exp = [
['id' => 5, 'name' => "Politics", 'parent' => null, 'children' => 0, 'feeds' => 2],
@@ -170,9 +163,6 @@ trait SeriesFolder {
$this->assertResult($exp, Arsse::$db->folderList("jane.doe@example.com", null, false));
$exp = [];
$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");
}
public function testListFoldersRecursively(): void {
@@ -192,8 +182,6 @@ trait SeriesFolder {
$this->assertResult($exp, Arsse::$db->folderList("john.doe@example.com", 1, true));
$exp = [];
$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");
}
public function testListFoldersOfAMissingParent(): void {
@@ -206,15 +194,8 @@ trait SeriesFolder {
Arsse::$db->folderList("john.doe@example.com", 4); // folder ID 4 belongs to Jane
}
- public function testListFoldersWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->folderList("john.doe@example.com");
- }
-
public function testRemoveAFolder(): void {
$this->assertTrue(Arsse::$db->folderRemove("john.doe@example.com", 6));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderRemove");
$state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]);
array_pop($state['arsse_folders']['rows']);
$this->compareExpectations(static::$drv, $state);
@@ -222,7 +203,6 @@ trait SeriesFolder {
public function testRemoveAFolderTree(): void {
$this->assertTrue(Arsse::$db->folderRemove("john.doe@example.com", 1));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderRemove");
$state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]);
foreach ([0,1,2,5] as $index) {
unset($state['arsse_folders']['rows'][$index]);
@@ -245,12 +225,6 @@ trait SeriesFolder {
Arsse::$db->folderRemove("john.doe@example.com", 4); // folder ID 4 belongs to Jane
}
- public function testRemoveAFolderWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->folderRemove("john.doe@example.com", 1);
- }
-
public function testGetThePropertiesOfAFolder(): void {
$exp = [
'id' => 6,
@@ -258,7 +232,6 @@ trait SeriesFolder {
'parent' => 2,
];
$this->assertArraySubset($exp, Arsse::$db->folderPropertiesGet("john.doe@example.com", 6));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderPropertiesGet");
}
public function testGetThePropertiesOfAMissingFolder(): void {
@@ -276,19 +249,12 @@ trait SeriesFolder {
Arsse::$db->folderPropertiesGet("john.doe@example.com", 4); // folder ID 4 belongs to Jane
}
- public function testGetThePropertiesOfAFolderWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->folderPropertiesGet("john.doe@example.com", 1);
- }
-
public function testMakeNoChangesToAFolder(): void {
$this->assertFalse(Arsse::$db->folderPropertiesSet("john.doe@example.com", 6, []));
}
public function testRenameAFolder(): void {
$this->assertTrue(Arsse::$db->folderPropertiesSet("john.doe@example.com", 6, ['name' => "Opinion"]));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderPropertiesSet");
$state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]);
$state['arsse_folders']['rows'][5][3] = "Opinion";
$this->compareExpectations(static::$drv, $state);
@@ -315,7 +281,6 @@ trait SeriesFolder {
public function testMoveAFolder(): void {
$this->assertTrue(Arsse::$db->folderPropertiesSet("john.doe@example.com", 6, ['parent' => 5]));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "folderPropertiesSet");
$state = $this->primeExpectations($this->data, ['arsse_folders' => ['id','owner', 'parent', 'name']]);
$state['arsse_folders']['rows'][5][2] = 5; // parent should have changed
$this->compareExpectations(static::$drv, $state);
@@ -370,10 +335,4 @@ trait SeriesFolder {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->folderPropertiesSet("john.doe@example.com", 4, ['parent' => null]); // folder ID 4 belongs to Jane
}
-
- public function testSetThePropertiesOfAFolderWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->folderPropertiesSet("john.doe@example.com", 1, ['parent' => null]);
- }
}
diff --git a/tests/cases/Database/SeriesIcon.php b/tests/cases/Database/SeriesIcon.php
new file mode 100644
index 00000000..667651f2
--- /dev/null
+++ b/tests/cases/Database/SeriesIcon.php
@@ -0,0 +1,97 @@
+data = [
+ 'arsse_users' => [
+ 'columns' => [
+ 'id' => 'str',
+ 'password' => 'str',
+ 'num' => 'int',
+ ],
+ 'rows' => [
+ ["jane.doe@example.com", "",1],
+ ["john.doe@example.com", "",2],
+ ],
+ ],
+ 'arsse_icons' => [
+ 'columns' => [
+ 'id' => "int",
+ 'url' => "str",
+ 'type' => "str",
+ 'data' => "blob",
+ ],
+ 'rows' => [
+ [1,'http://localhost:8000/Icon/PNG','image/png',base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMjHxIGmVAAAADUlEQVQYV2NgYGBgAAAABQABijPjAAAAAABJRU5ErkJggg==")],
+ [2,'http://localhost:8000/Icon/GIF','image/gif',base64_decode("R0lGODlhAQABAIABAAAAAP///yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==")],
+ [3,'http://localhost:8000/Icon/SVG1','image/svg+xml',' '],
+ [4,'http://localhost:8000/Icon/SVG2','image/svg+xml',' '],
+ ],
+ ],
+ 'arsse_feeds' => [
+ 'columns' => [
+ 'id' => "int",
+ 'url' => "str",
+ 'title' => "str",
+ 'err_count' => "int",
+ 'err_msg' => "str",
+ 'modified' => "datetime",
+ 'next_fetch' => "datetime",
+ 'size' => "int",
+ 'icon' => "int",
+ ],
+ 'rows' => [
+ [1,"http://localhost:8000/Feed/Matching/3","Ook",0,"",$past,$past,0,1],
+ [2,"http://localhost:8000/Feed/Matching/1","Eek",5,"There was an error last time",$past,$future,0,2],
+ [3,"http://localhost:8000/Feed/Fetching/Error?code=404","Ack",0,"",$past,$now,0,3],
+ [4,"http://localhost:8000/Feed/NextFetch/NotModified?t=".time(),"Ooook",0,"",$past,$past,0,null],
+ [5,"http://localhost:8000/Feed/Parsing/Valid","Ooook",0,"",$past,$future,0,2],
+ ],
+ ],
+ 'arsse_subscriptions' => [
+ 'columns' => [
+ 'id' => "int",
+ 'owner' => "str",
+ 'feed' => "int",
+ ],
+ 'rows' => [
+ [1,'john.doe@example.com',1],
+ [2,'john.doe@example.com',2],
+ [3,'john.doe@example.com',3],
+ [4,'john.doe@example.com',4],
+ [5,'john.doe@example.com',5],
+ [6,'jane.doe@example.com',5],
+ ],
+ ],
+ ];
+ }
+
+ protected function tearDownSeriesIcon(): void {
+ unset($this->data);
+ }
+
+ public function testListTheIconsOfAUser() {
+ $exp = [
+ ['id' => 1,'url' => 'http://localhost:8000/Icon/PNG', 'type' => 'image/png', 'data' => base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMjHxIGmVAAAADUlEQVQYV2NgYGBgAAAABQABijPjAAAAAABJRU5ErkJggg==")],
+ ['id' => 2,'url' => 'http://localhost:8000/Icon/GIF', 'type' => 'image/gif', 'data' => base64_decode("R0lGODlhAQABAIABAAAAAP///yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==")],
+ ['id' => 3,'url' => 'http://localhost:8000/Icon/SVG1', 'type' => 'image/svg+xml', 'data' => ' '],
+ ];
+ $this->assertResult($exp, Arsse::$db->iconList("john.doe@example.com"));
+ $exp = [
+ ['id' => 2,'url' => 'http://localhost:8000/Icon/GIF', 'type' => 'image/gif', 'data' => base64_decode("R0lGODlhAQABAIABAAAAAP///yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==")],
+ ];
+ $this->assertResult($exp, Arsse::$db->iconList("jane.doe@example.com"));
+ }
+}
diff --git a/tests/cases/Database/SeriesLabel.php b/tests/cases/Database/SeriesLabel.php
index db9c4989..4a4fac66 100644
--- a/tests/cases/Database/SeriesLabel.php
+++ b/tests/cases/Database/SeriesLabel.php
@@ -17,12 +17,13 @@ trait SeriesLabel {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
- ["john.doe@example.org", ""],
- ["john.doe@example.net", ""],
+ ["jane.doe@example.com", "",1],
+ ["john.doe@example.com", "",2],
+ ["john.doe@example.org", "",3],
+ ["john.doe@example.net", "",4],
],
],
'arsse_folders' => [
@@ -193,20 +194,22 @@ trait SeriesLabel {
'read' => "bool",
'starred' => "bool",
'modified' => "datetime",
+ 'hidden' => "bool",
],
'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',0],
+ [5, 19,1,0,'2000-01-01 00:00:00',0],
+ [5, 20,0,1,'2010-01-01 00:00:00',0],
+ [7, 20,1,0,'2010-01-01 00:00:00',0],
+ [8, 102,1,0,'2000-01-02 02:00:00',0],
+ [9, 103,0,1,'2000-01-03 03:00:00',0],
+ [9, 104,1,1,'2000-01-04 04:00:00',0],
+ [10,105,0,0,'2000-01-05 05:00:00',0],
+ [11, 19,0,0,'2017-01-01 00:00:00',0],
+ [11, 20,1,0,'2017-01-01 00:00:00',0],
+ [12, 3,0,1,'2017-01-01 00:00:00',0],
+ [12, 4,1,1,'2017-01-01 00:00:00',0],
+ [4, 8,0,0,'2000-01-02 02:00:00',1],
],
],
'arsse_labels' => [
@@ -236,6 +239,7 @@ trait SeriesLabel {
[2,20,5,1],
[1, 5,3,0],
[2, 5,3,1],
+ [2, 8,4,1],
],
],
];
@@ -252,7 +256,6 @@ trait SeriesLabel {
$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(static::$drv, $state);
@@ -278,12 +281,6 @@ trait SeriesLabel {
Arsse::$db->labelAdd("john.doe@example.com", ['name' => " "]);
}
- public function testAddALabelWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->labelAdd("john.doe@example.com", ['name' => "Boring"]);
- }
-
public function testListLabels(): void {
$exp = [
['id' => 2, 'name' => "Fascinating", 'articles' => 3, 'read' => 1],
@@ -297,18 +294,10 @@ trait SeriesLabel {
$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(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->labelList("john.doe@example.com");
}
public function testRemoveALabel(): void {
$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(static::$drv, $state);
@@ -316,7 +305,6 @@ trait SeriesLabel {
public function testRemoveALabelByName(): void {
$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(static::$drv, $state);
@@ -342,12 +330,6 @@ trait SeriesLabel {
Arsse::$db->labelRemove("john.doe@example.com", 3); // label ID 3 belongs to Jane
}
- public function testRemoveALabelWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->labelRemove("john.doe@example.com", 1);
- }
-
public function testGetThePropertiesOfALabel(): void {
$exp = [
'id' => 2,
@@ -357,7 +339,6 @@ trait SeriesLabel {
];
$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(): void {
@@ -380,19 +361,12 @@ trait SeriesLabel {
Arsse::$db->labelPropertiesGet("john.doe@example.com", 3); // label ID 3 belongs to Jane
}
- public function testGetThePropertiesOfALabelWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->labelPropertiesGet("john.doe@example.com", 1);
- }
-
public function testMakeNoChangesToALabel(): void {
$this->assertFalse(Arsse::$db->labelPropertiesSet("john.doe@example.com", 1, []));
}
public function testRenameALabel(): void {
$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(static::$drv, $state);
@@ -400,7 +374,6 @@ trait SeriesLabel {
public function testRenameALabelByName(): void {
$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(static::$drv, $state);
@@ -446,12 +419,6 @@ trait SeriesLabel {
Arsse::$db->labelPropertiesSet("john.doe@example.com", 3, ['name' => "Exciting"]); // label ID 3 belongs to Jane
}
- public function testSetThePropertiesOfALabelWithoutAuthority(): void {
- \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(): void {
$exp = [1,19];
$this->assertEquals($exp, Arsse::$db->labelArticlesGet("john.doe@example.com", 1));
@@ -474,12 +441,6 @@ trait SeriesLabel {
Arsse::$db->labelArticlesGet("john.doe@example.com", -1);
}
- public function testListLabelledArticlesWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->labelArticlesGet("john.doe@example.com", 1);
- }
-
public function testApplyALabelToArticles(): void {
Arsse::$db->labelArticlesSet("john.doe@example.com", 1, (new Context)->articles([2,5]));
$state = $this->primeExpectations($this->data, $this->checkMembers);
@@ -539,10 +500,4 @@ trait SeriesLabel {
$state['arsse_label_members']['rows'][2][3] = 0;
$this->compareExpectations(static::$drv, $state);
}
-
- public function testApplyALabelToArticlesWithoutAuthority(): void {
- \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]));
- }
}
diff --git a/tests/cases/Database/SeriesSession.php b/tests/cases/Database/SeriesSession.php
index 9a354f66..1db319f8 100644
--- a/tests/cases/Database/SeriesSession.php
+++ b/tests/cases/Database/SeriesSession.php
@@ -26,10 +26,11 @@ trait SeriesSession {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
+ ["jane.doe@example.com", "",1],
+ ["john.doe@example.com", "",2],
],
],
'arsse_sessions' => [
@@ -69,9 +70,6 @@ trait SeriesSession {
$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(static::$drv, $state);
- // session resumption should not check authorization
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertArraySubset($exp1, Arsse::$db->sessionResume("80fa94c1a11f11e78667001e673b2560"));
}
public function testResumeAMissingSession(): void {
@@ -98,12 +96,6 @@ trait SeriesSession {
$this->compareExpectations(static::$drv, $state);
}
- public function testCreateASessionWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->sessionCreate("jane.doe@example.com");
- }
-
public function testDestroyASession(): void {
$user = "jane.doe@example.com";
$id = "80fa94c1a11f11e78667001e673b2560";
@@ -130,10 +122,4 @@ trait SeriesSession {
$id = "80fa94c1a11f11e78667001e673b2560";
$this->assertFalse(Arsse::$db->sessionDestroy($user, $id));
}
-
- public function testDestroyASessionWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->sessionDestroy("jane.doe@example.com", "80fa94c1a11f11e78667001e673b2560");
- }
}
diff --git a/tests/cases/Database/SeriesSubscription.php b/tests/cases/Database/SeriesSubscription.php
index d8614e24..0b5f6512 100644
--- a/tests/cases/Database/SeriesSubscription.php
+++ b/tests/cases/Database/SeriesSubscription.php
@@ -18,10 +18,13 @@ trait SeriesSubscription {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
+ ["jane.doe@example.com", "", 1],
+ ["john.doe@example.com", "", 2],
+ ["jill.doe@example.com", "", 3],
+ ["jack.doe@example.com", "", 4],
],
],
'arsse_folders' => [
@@ -40,6 +43,17 @@ trait SeriesSubscription {
[6, "john.doe@example.com", 2, "Politics"],
],
],
+ 'arsse_icons' => [
+ 'columns' => [
+ 'id' => "int",
+ 'url' => "str",
+ 'data' => "blob",
+ ],
+ 'rows' => [
+ [1,"http://example.com/favicon.ico", "ICON DATA"],
+ [2,"http://example.net/favicon.ico", null],
+ ],
+ ],
'arsse_feeds' => [
'columns' => [
'id' => "int",
@@ -49,9 +63,14 @@ trait SeriesSubscription {
'password' => "str",
'updated' => "datetime",
'next_fetch' => "datetime",
- 'favicon' => "str",
+ 'icon' => "int",
+ ],
+ 'rows' => [
+ [1,"http://example.com/feed1", "Ook", "", "",strtotime("now"),strtotime("now"),null],
+ [2,"http://example.com/feed2", "eek", "", "",strtotime("now - 1 hour"),strtotime("now - 1 hour"),1],
+ [3,"http://example.com/feed3", "Ack", "", "",strtotime("now + 1 hour"),strtotime("now + 1 hour"),2],
+ [4,"http://example.com/feed4", "Foo", "", "",strtotime("now + 1 hour"),strtotime("now + 1 hour"),null],
],
- 'rows' => [], // filled in the series setup
],
'arsse_subscriptions' => [
'columns' => [
@@ -62,11 +81,17 @@ trait SeriesSubscription {
'folder' => "int",
'pinned' => "bool",
'order_type' => "int",
+ 'keep_rule' => "str",
+ 'block_rule' => "str",
+ 'scrape' => "bool",
],
'rows' => [
- [1,"john.doe@example.com",2,null,null,1,2],
- [2,"jane.doe@example.com",2,null,null,0,0],
- [3,"john.doe@example.com",3,"Ook",2,0,1],
+ [1,"john.doe@example.com",2,null,null,1,2,null,null,0],
+ [2,"jane.doe@example.com",2,null,null,0,0,null,null,0],
+ [3,"john.doe@example.com",3,"Ook",2,0,1,null,null,0],
+ [4,"jill.doe@example.com",2,null,null,0,0,null,null,0],
+ [5,"jack.doe@example.com",2,null,null,1,2,"","3|E",0],
+ [6,"john.doe@example.com",4,"Bar",3,0,0,null,null,0],
],
],
'arsse_tags' => [
@@ -103,16 +128,48 @@ trait SeriesSubscription {
'url_title_hash' => "str",
'url_content_hash' => "str",
'title_content_hash' => "str",
+ 'title' => "str",
],
'rows' => [
- [1,2,"","",""],
- [2,2,"","",""],
- [3,2,"","",""],
- [4,2,"","",""],
- [5,2,"","",""],
- [6,3,"","",""],
- [7,3,"","",""],
- [8,3,"","",""],
+ [1,2,"","","","Title 1"],
+ [2,2,"","","","Title 2"],
+ [3,2,"","","","Title 3"],
+ [4,2,"","","","Title 4"],
+ [5,2,"","","","Title 5"],
+ [6,3,"","","","Title 6"],
+ [7,3,"","","","Title 7"],
+ [8,3,"","","","Title 8"],
+ ],
+ ],
+ 'arsse_editions' => [
+ 'columns' => [
+ 'id' => "int",
+ 'article' => "int",
+ ],
+ 'rows' => [
+ [1,1],
+ [2,2],
+ [3,3],
+ [4,4],
+ [5,5],
+ [6,6],
+ [7,7],
+ [8,8],
+ ],
+ ],
+ 'arsse_categories' => [
+ 'columns' => [
+ 'article' => "int",
+ 'name' => "str",
+ ],
+ 'rows' => [
+ [1,"A"],
+ [2,"B"],
+ [4,"D"],
+ [5,"E"],
+ [6,"F"],
+ [7,"G"],
+ [8,"H"],
],
],
'arsse_marks' => [
@@ -121,26 +178,24 @@ trait SeriesSubscription {
'subscription' => "int",
'read' => "bool",
'starred' => "bool",
+ 'hidden' => "bool",
],
'rows' => [
- [1,2,1,0],
- [2,2,1,0],
- [3,2,1,0],
- [4,2,1,0],
- [5,2,1,0],
- [1,1,1,0],
- [7,3,1,0],
- [8,3,0,0],
+ [1,2,1,0,0],
+ [2,2,1,0,0],
+ [3,2,1,0,0],
+ [4,2,1,0,0],
+ [5,2,1,0,0],
+ [1,1,1,0,0],
+ [7,3,1,0,0],
+ [8,3,0,0,0],
+ [1,5,1,0,0],
+ [3,5,1,0,1],
+ [4,5,0,0,0],
+ [5,5,0,0,1],
],
],
];
- $this->data['arsse_feeds']['rows'] = [
- [1,"http://example.com/feed1", "Ook", "", "",strtotime("now"),strtotime("now"),''],
- [2,"http://example.com/feed2", "eek", "", "",strtotime("now - 1 hour"),strtotime("now - 1 hour"),'http://example.com/favicon.ico'],
- [3,"http://example.com/feed3", "Ack", "", "",strtotime("now + 1 hour"),strtotime("now + 1 hour"),''],
- ];
- // initialize a partial mock of the Database object to later manipulate the feedUpdate method
- Arsse::$db = \Phake::partialMock(Database::class, static::$drv);
$this->user = "john.doe@example.com";
}
@@ -151,10 +206,11 @@ trait SeriesSubscription {
public function testAddASubscriptionToAnExistingFeed(): void {
$url = "http://example.com/feed1";
$subID = $this->nextID("arsse_subscriptions");
- \Phake::when(Arsse::$db)->feedUpdate->thenReturn(true);
+ $db = $this->partialMock(Database::class, static::$drv);
+ $db->feedUpdate->returns(true);
+ Arsse::$db = $db->get();
$this->assertSame($subID, Arsse::$db->subscriptionAdd($this->user, $url));
- \Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd");
- \Phake::verify(Arsse::$db, \Phake::times(0))->feedUpdate(1, true);
+ $db->feedUpdate->never()->called();
$state = $this->primeExpectations($this->data, [
'arsse_feeds' => ['id','url','username','password'],
'arsse_subscriptions' => ['id','owner','feed'],
@@ -167,10 +223,11 @@ trait SeriesSubscription {
$url = "http://example.org/feed1";
$feedID = $this->nextID("arsse_feeds");
$subID = $this->nextID("arsse_subscriptions");
- \Phake::when(Arsse::$db)->feedUpdate->thenReturn(true);
+ $db = $this->partialMock(Database::class, static::$drv);
+ $db->feedUpdate->returns(true);
+ Arsse::$db = $db->get();
$this->assertSame($subID, Arsse::$db->subscriptionAdd($this->user, $url, "", "", false));
- \Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd");
- \Phake::verify(Arsse::$db)->feedUpdate($feedID, true);
+ $db->feedUpdate->calledWith($feedID, true, false);
$state = $this->primeExpectations($this->data, [
'arsse_feeds' => ['id','url','username','password'],
'arsse_subscriptions' => ['id','owner','feed'],
@@ -185,10 +242,11 @@ trait SeriesSubscription {
$discovered = "http://localhost:8000/Feed/Discovery/Feed";
$feedID = $this->nextID("arsse_feeds");
$subID = $this->nextID("arsse_subscriptions");
- \Phake::when(Arsse::$db)->feedUpdate->thenReturn(true);
+ $db = $this->partialMock(Database::class, static::$drv);
+ $db->feedUpdate->returns(true);
+ Arsse::$db = $db->get();
$this->assertSame($subID, Arsse::$db->subscriptionAdd($this->user, $url, "", "", true));
- \Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd");
- \Phake::verify(Arsse::$db)->feedUpdate($feedID, true);
+ $db->feedUpdate->calledWith($feedID, true, false);
$state = $this->primeExpectations($this->data, [
'arsse_feeds' => ['id','url','username','password'],
'arsse_subscriptions' => ['id','owner','feed'],
@@ -201,13 +259,14 @@ trait SeriesSubscription {
public function testAddASubscriptionToAnInvalidFeed(): void {
$url = "http://example.org/feed1";
$feedID = $this->nextID("arsse_feeds");
- \Phake::when(Arsse::$db)->feedUpdate->thenThrow(new FeedException($url, $this->mockGuzzleException(ClientException::class, "", 404)));
+ $db = $this->partialMock(Database::class, static::$drv);
+ $db->feedUpdate->throws(new FeedException("", ['url' => $url], $this->mockGuzzleException(ClientException::class, "", 404)));
+ Arsse::$db = $db->get();
$this->assertException("invalidUrl", "Feed");
try {
Arsse::$db->subscriptionAdd($this->user, $url, "", "", false);
} finally {
- \Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionAdd");
- \Phake::verify(Arsse::$db)->feedUpdate($feedID, true);
+ $db->feedUpdate->calledWith($feedID, true, false);
$state = $this->primeExpectations($this->data, [
'arsse_feeds' => ['id','url','username','password'],
'arsse_subscriptions' => ['id','owner','feed'],
@@ -236,16 +295,8 @@ trait SeriesSubscription {
$this->assertSame($subID, Arsse::$db->subscriptionAdd($this->user, $url));
}
- public function testAddASubscriptionWithoutAuthority(): void {
- $url = "http://example.com/feed1";
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->subscriptionAdd($this->user, $url);
- }
-
public function testRemoveASubscription(): void {
$this->assertTrue(Arsse::$db->subscriptionRemove($this->user, 1));
- \Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionRemove");
$state = $this->primeExpectations($this->data, [
'arsse_feeds' => ['id','url','username','password'],
'arsse_subscriptions' => ['id','owner','feed'],
@@ -270,38 +321,64 @@ trait SeriesSubscription {
Arsse::$db->subscriptionRemove($this->user, 1);
}
- public function testRemoveASubscriptionWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->subscriptionRemove($this->user, 1);
- }
-
public function testListSubscriptions(): void {
+ $exp = [
+ [
+ 'url' => "http://example.com/feed2",
+ 'title' => "eek",
+ 'folder' => null,
+ 'top_folder' => null,
+ 'folder_name' => null,
+ 'top_folder_name' => null,
+ 'unread' => 4,
+ 'pinned' => 1,
+ 'order_type' => 2,
+ 'icon_url' => "http://example.com/favicon.ico",
+ 'icon_id' => 1,
+ ],
+ [
+ 'url' => "http://example.com/feed3",
+ 'title' => "Ook",
+ 'folder' => 2,
+ 'top_folder' => 1,
+ 'folder_name' => "Software",
+ 'top_folder_name' => "Technology",
+ 'unread' => 2,
+ 'pinned' => 0,
+ 'order_type' => 1,
+ 'icon_url' => "http://example.net/favicon.ico",
+ 'icon_id' => null,
+ ],
+ [
+ 'url' => "http://example.com/feed4",
+ 'title' => "Bar",
+ 'folder' => 3,
+ 'top_folder' => 1,
+ 'folder_name' => "Rocketry",
+ 'top_folder_name' => "Technology",
+ 'unread' => 0,
+ 'pinned' => 0,
+ 'order_type' => 0,
+ 'icon_url' => null,
+ 'icon_id' => null,
+ ],
+ ];
+ $this->assertResult($exp, Arsse::$db->subscriptionList($this->user));
+ $this->assertArraySubset($exp[0], Arsse::$db->subscriptionPropertiesGet($this->user, 1));
+ $this->assertArraySubset($exp[1], Arsse::$db->subscriptionPropertiesGet($this->user, 3));
+ // test that an absence of marks does not corrupt unread count
$exp = [
[
'url' => "http://example.com/feed2",
'title' => "eek",
'folder' => null,
'top_folder' => null,
- 'unread' => 4,
- 'pinned' => 1,
- 'order_type' => 2,
- ],
- [
- 'url' => "http://example.com/feed3",
- 'title' => "Ook",
- 'folder' => 2,
- 'top_folder' => 1,
- 'unread' => 2,
+ 'unread' => 5,
'pinned' => 0,
- 'order_type' => 1,
+ 'order_type' => 0,
],
];
- $this->assertResult($exp, Arsse::$db->subscriptionList($this->user));
- \Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionList");
- $this->assertArraySubset($exp[0], Arsse::$db->subscriptionPropertiesGet($this->user, 1));
- \Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionPropertiesGet");
- $this->assertArraySubset($exp[1], Arsse::$db->subscriptionPropertiesGet($this->user, 3));
+ $this->assertResult($exp, Arsse::$db->subscriptionList("jill.doe@example.com"));
}
public function testListSubscriptionsInAFolder(): void {
@@ -319,7 +396,7 @@ trait SeriesSubscription {
$this->assertResult($exp, Arsse::$db->subscriptionList($this->user, null, false));
}
- public function testListSubscriptionsWithoutRecursion(): void {
+ public function testListSubscriptionsWithRecursion(): void {
$exp = [
[
'url' => "http://example.com/feed3",
@@ -339,14 +416,8 @@ trait SeriesSubscription {
Arsse::$db->subscriptionList($this->user, 4);
}
- public function testListSubscriptionsWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->subscriptionList($this->user);
- }
-
public function testCountSubscriptions(): void {
- $this->assertSame(2, Arsse::$db->subscriptionCount($this->user));
+ $this->assertSame(3, Arsse::$db->subscriptionCount($this->user));
$this->assertSame(1, Arsse::$db->subscriptionCount($this->user, 2));
}
@@ -355,12 +426,6 @@ trait SeriesSubscription {
Arsse::$db->subscriptionCount($this->user, 4);
}
- public function testCountSubscriptionsWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->subscriptionCount($this->user);
- }
-
public function testGetThePropertiesOfAMissingSubscription(): void {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->subscriptionPropertiesGet($this->user, 2112);
@@ -371,30 +436,28 @@ trait SeriesSubscription {
Arsse::$db->subscriptionPropertiesGet($this->user, -1);
}
- public function testGetThePropertiesOfASubscriptionWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->subscriptionPropertiesGet($this->user, 1);
- }
-
public function testSetThePropertiesOfASubscription(): void {
Arsse::$db->subscriptionPropertiesSet($this->user, 1, [
'title' => "Ook Ook",
'folder' => 3,
'pinned' => false,
+ 'scrape' => true,
'order_type' => 0,
+ 'keep_rule' => "ook",
+ 'block_rule' => "eek",
]);
- \Phake::verify(Arsse::$user)->authorize($this->user, "subscriptionPropertiesSet");
$state = $this->primeExpectations($this->data, [
'arsse_feeds' => ['id','url','username','password','title'],
- 'arsse_subscriptions' => ['id','owner','feed','title','folder','pinned','order_type'],
+ 'arsse_subscriptions' => ['id','owner','feed','title','folder','pinned','order_type','keep_rule','block_rule','scrape'],
]);
- $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,"Ook Ook",3,0,0];
+ $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,"Ook Ook",3,0,0,"ook","eek",1];
$this->compareExpectations(static::$drv, $state);
Arsse::$db->subscriptionPropertiesSet($this->user, 1, [
- 'title' => null,
+ 'title' => null,
+ 'keep_rule' => null,
+ 'block_rule' => null,
]);
- $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,null,3,0,0];
+ $state['arsse_subscriptions']['rows'][0] = [1,"john.doe@example.com",2,null,3,0,0,null,null,1];
$this->compareExpectations(static::$drv, $state);
// making no changes is a valid result
Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['unhinged' => true]);
@@ -410,30 +473,28 @@ trait SeriesSubscription {
$this->assertTrue(Arsse::$db->subscriptionPropertiesSet($this->user, 3, ['folder' => null]));
}
- public function testRenameASubscriptionToABlankTitle(): void {
- $this->assertException("missing", "Db", "ExceptionInput");
- Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => ""]);
+ /** @dataProvider provideInvalidSubscriptionProperties */
+ public function testSetThePropertiesOfASubscriptionToInvalidValues(array $data, string $exp): void {
+ $this->assertException($exp, "Db", "ExceptionInput");
+ Arsse::$db->subscriptionPropertiesSet($this->user, 1, $data);
}
- public function testRenameASubscriptionToAWhitespaceTitle(): void {
- $this->assertException("whitespace", "Db", "ExceptionInput");
- Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => " "]);
- }
-
- public function testRenameASubscriptionToFalse(): void {
- $this->assertException("typeViolation", "Db", "ExceptionInput");
- Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => false]);
+ public function provideInvalidSubscriptionProperties(): iterable {
+ return [
+ 'Empty title' => [['title' => ""], "missing"],
+ 'Whitespace title' => [['title' => " "], "whitespace"],
+ 'Non-string title' => [['title' => []], "typeViolation"],
+ 'Non-string keep rule' => [['keep_rule' => 0], "typeViolation"],
+ 'Invalid keep rule' => [['keep_rule' => "*"], "invalidValue"],
+ 'Non-string block rule' => [['block_rule' => 0], "typeViolation"],
+ 'Invalid block rule' => [['block_rule' => "*"], "invalidValue"],
+ ];
}
public function testRenameASubscriptionToZero(): void {
$this->assertTrue(Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => 0]));
}
- public function testRenameASubscriptionToAnArray(): void {
- $this->assertException("typeViolation", "Db", "ExceptionInput");
- Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['title' => []]);
- }
-
public function testSetThePropertiesOfAMissingSubscription(): void {
$this->assertException("subjectMissing", "Db", "ExceptionInput");
Arsse::$db->subscriptionPropertiesSet($this->user, 2112, ['folder' => null]);
@@ -444,48 +505,31 @@ trait SeriesSubscription {
Arsse::$db->subscriptionPropertiesSet($this->user, -1, ['folder' => null]);
}
- public function testSetThePropertiesOfASubscriptionWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->subscriptionPropertiesSet($this->user, 1, ['folder' => null]);
- }
-
public function testRetrieveTheFaviconOfASubscription(): void {
$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));
+ $this->assertSame($exp, Arsse::$db->subscriptionIcon(null, 1)['url']);
+ $this->assertSame($exp, Arsse::$db->subscriptionIcon(null, 2)['url']);
+ $this->assertSame(null, Arsse::$db->subscriptionIcon(null, 6));
+ }
+
+ public function testRetrieveTheFaviconOfAMissingSubscription(): void {
+ $this->assertException("subjectMissing", "Db", "ExceptionInput");
+ Arsse::$db->subscriptionIcon(null, -2112);
}
public function testRetrieveTheFaviconOfASubscriptionWithUser(): void {
$exp = "http://example.com/favicon.ico";
$user = "john.doe@example.com";
- $this->assertSame($exp, Arsse::$db->subscriptionFavicon(1, $user));
- $this->assertSame('', Arsse::$db->subscriptionFavicon(2, $user));
- $this->assertSame('', Arsse::$db->subscriptionFavicon(3, $user));
- $this->assertSame('', Arsse::$db->subscriptionFavicon(4, $user));
+ $this->assertSame($exp, Arsse::$db->subscriptionIcon($user, 1)['url']);
+ $this->assertSame(null, Arsse::$db->subscriptionIcon($user, 6));
$user = "jane.doe@example.com";
- $this->assertSame('', Arsse::$db->subscriptionFavicon(1, $user));
- $this->assertSame($exp, Arsse::$db->subscriptionFavicon(2, $user));
- $this->assertSame('', Arsse::$db->subscriptionFavicon(3, $user));
- $this->assertSame('', Arsse::$db->subscriptionFavicon(4, $user));
+ $this->assertSame($exp, Arsse::$db->subscriptionIcon($user, 2)['url']);
}
- public function testRetrieveTheFaviconOfASubscriptionWithUserWithoutAuthority(): void {
- $exp = "http://example.com/favicon.ico";
+ public function testRetrieveTheFaviconOfASubscriptionOfTheWrongUser(): void {
$user = "john.doe@example.com";
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->subscriptionFavicon(-2112, $user);
+ $this->assertException("subjectMissing", "Db", "ExceptionInput");
+ Arsse::$db->subscriptionIcon($user, 2);
}
public function testListTheTagsOfASubscription(): void {
@@ -500,12 +544,6 @@ trait SeriesSubscription {
Arsse::$db->subscriptionTagsGet($this->user, 101);
}
- public function testListTheTagsOfASubscriptionWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->subscriptionTagsGet("john.doe@example.com", 1);
- }
-
public function testGetRefreshTimeOfASubscription(): void {
$user = "john.doe@example.com";
$this->assertTime(strtotime("now + 1 hour"), Arsse::$db->subscriptionRefreshed($user));
@@ -517,9 +555,11 @@ trait SeriesSubscription {
$this->assertTime(strtotime("now - 1 hour"), Arsse::$db->subscriptionRefreshed("john.doe@example.com", 2));
}
- public function testGetRefreshTimeOfASubscriptionWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- $this->assertTime(strtotime("now + 1 hour"), Arsse::$db->subscriptionRefreshed("john.doe@example.com"));
+ public function testSetTheFilterRulesOfASubscriptionCheckingMarks(): void {
+ Arsse::$db->subscriptionPropertiesSet("jack.doe@example.com", 5, ['keep_rule' => "1|B|3|D", 'block_rule' => "4"]);
+ $state = $this->primeExpectations($this->data, ['arsse_marks' => ['article', 'subscription', 'hidden']]);
+ $state['arsse_marks']['rows'][9][2] = 0;
+ $state['arsse_marks']['rows'][10][2] = 1;
+ $this->compareExpectations(static::$drv, $state);
}
}
diff --git a/tests/cases/Database/SeriesTag.php b/tests/cases/Database/SeriesTag.php
index f6a3f4ea..1f2ea9cd 100644
--- a/tests/cases/Database/SeriesTag.php
+++ b/tests/cases/Database/SeriesTag.php
@@ -16,12 +16,13 @@ trait SeriesTag {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
- ["john.doe@example.org", ""],
- ["john.doe@example.net", ""],
+ ["jane.doe@example.com", "",1],
+ ["john.doe@example.com", "",2],
+ ["john.doe@example.org", "",3],
+ ["john.doe@example.net", "",4],
],
],
'arsse_feeds' => [
@@ -112,7 +113,6 @@ trait SeriesTag {
$user = "john.doe@example.com";
$tagID = $this->nextID("arsse_tags");
$this->assertSame($tagID, Arsse::$db->tagAdd($user, ['name' => "Entertaining"]));
- \Phake::verify(Arsse::$user)->authorize($user, "tagAdd");
$state = $this->primeExpectations($this->data, $this->checkTags);
$state['arsse_tags']['rows'][] = [$tagID, $user, "Entertaining"];
$this->compareExpectations(static::$drv, $state);
@@ -138,12 +138,6 @@ trait SeriesTag {
Arsse::$db->tagAdd("john.doe@example.com", ['name' => " "]);
}
- public function testAddATagWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tagAdd("john.doe@example.com", ['name' => "Boring"]);
- }
-
public function testListTags(): void {
$exp = [
['id' => 2, 'name' => "Fascinating"],
@@ -157,18 +151,10 @@ trait SeriesTag {
$this->assertResult($exp, Arsse::$db->tagList("jane.doe@example.com"));
$exp = [];
$this->assertResult($exp, Arsse::$db->tagList("jane.doe@example.com", false));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagList");
- }
-
- public function testListTagsWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tagList("john.doe@example.com");
}
public function testRemoveATag(): void {
$this->assertTrue(Arsse::$db->tagRemove("john.doe@example.com", 1));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagRemove");
$state = $this->primeExpectations($this->data, $this->checkTags);
array_shift($state['arsse_tags']['rows']);
$this->compareExpectations(static::$drv, $state);
@@ -176,7 +162,6 @@ trait SeriesTag {
public function testRemoveATagByName(): void {
$this->assertTrue(Arsse::$db->tagRemove("john.doe@example.com", "Interesting", true));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagRemove");
$state = $this->primeExpectations($this->data, $this->checkTags);
array_shift($state['arsse_tags']['rows']);
$this->compareExpectations(static::$drv, $state);
@@ -202,12 +187,6 @@ trait SeriesTag {
Arsse::$db->tagRemove("john.doe@example.com", 3); // tag ID 3 belongs to Jane
}
- public function testRemoveATagWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tagRemove("john.doe@example.com", 1);
- }
-
public function testGetThePropertiesOfATag(): void {
$exp = [
'id' => 2,
@@ -215,7 +194,6 @@ trait SeriesTag {
];
$this->assertArraySubset($exp, Arsse::$db->tagPropertiesGet("john.doe@example.com", 2));
$this->assertArraySubset($exp, Arsse::$db->tagPropertiesGet("john.doe@example.com", "Fascinating", true));
- \Phake::verify(Arsse::$user, \Phake::times(2))->authorize("john.doe@example.com", "tagPropertiesGet");
}
public function testGetThePropertiesOfAMissingTag(): void {
@@ -238,19 +216,12 @@ trait SeriesTag {
Arsse::$db->tagPropertiesGet("john.doe@example.com", 3); // tag ID 3 belongs to Jane
}
- public function testGetThePropertiesOfATagWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tagPropertiesGet("john.doe@example.com", 1);
- }
-
public function testMakeNoChangesToATag(): void {
$this->assertFalse(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, []));
}
public function testRenameATag(): void {
$this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => "Curious"]));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagPropertiesSet");
$state = $this->primeExpectations($this->data, $this->checkTags);
$state['arsse_tags']['rows'][0][2] = "Curious";
$this->compareExpectations(static::$drv, $state);
@@ -258,7 +229,6 @@ trait SeriesTag {
public function testRenameATagByName(): void {
$this->assertTrue(Arsse::$db->tagPropertiesSet("john.doe@example.com", "Interesting", ['name' => "Curious"], true));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.com", "tagPropertiesSet");
$state = $this->primeExpectations($this->data, $this->checkTags);
$state['arsse_tags']['rows'][0][2] = "Curious";
$this->compareExpectations(static::$drv, $state);
@@ -304,12 +274,6 @@ trait SeriesTag {
Arsse::$db->tagPropertiesSet("john.doe@example.com", 3, ['name' => "Exciting"]); // tag ID 3 belongs to Jane
}
- public function testSetThePropertiesOfATagWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tagPropertiesSet("john.doe@example.com", 1, ['name' => "Exciting"]);
- }
-
public function testListTaggedSubscriptions(): void {
$exp = [1,5];
$this->assertEquals($exp, Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 1));
@@ -332,12 +296,6 @@ trait SeriesTag {
Arsse::$db->tagSubscriptionsGet("john.doe@example.com", -1);
}
- public function testListTaggedSubscriptionsWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tagSubscriptionsGet("john.doe@example.com", 1);
- }
-
public function testApplyATagToSubscriptions(): void {
Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [3,4]);
$state = $this->primeExpectations($this->data, $this->checkMembers);
@@ -398,12 +356,6 @@ trait SeriesTag {
$this->compareExpectations(static::$drv, $state);
}
- public function testApplyATagToSubscriptionsWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tagSubscriptionsSet("john.doe@example.com", 1, [3,4]);
- }
-
public function testSummarizeTags(): void {
$exp = [
['id' => 1, 'name' => "Interesting", 'subscription' => 1],
@@ -414,10 +366,4 @@ trait SeriesTag {
];
$this->assertResult($exp, Arsse::$db->tagSummarize("john.doe@example.com"));
}
-
- public function testSummarizeTagsWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tagSummarize("john.doe@example.com");
- }
}
diff --git a/tests/cases/Database/SeriesToken.php b/tests/cases/Database/SeriesToken.php
index aad4a875..7a14ed0d 100644
--- a/tests/cases/Database/SeriesToken.php
+++ b/tests/cases/Database/SeriesToken.php
@@ -20,10 +20,11 @@ trait SeriesToken {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
+ ["jane.doe@example.com", "",1],
+ ["john.doe@example.com", "",2],
],
],
'arsse_tokens' => [
@@ -32,12 +33,16 @@ trait SeriesToken {
'class' => "str",
'user' => "str",
'expires' => "datetime",
+ 'data' => "str",
],
'rows' => [
- ["80fa94c1a11f11e78667001e673b2560", "fever.login", "jane.doe@example.com", $faroff],
- ["27c6de8da13311e78667001e673b2560", "fever.login", "jane.doe@example.com", $past], // expired
- ["ab3b3eb8a13311e78667001e673b2560", "class.class", "jane.doe@example.com", null],
- ["da772f8fa13c11e78667001e673b2560", "class.class", "john.doe@example.com", $future],
+ ["80fa94c1a11f11e78667001e673b2560", "fever.login", "jane.doe@example.com", $faroff, null],
+ ["27c6de8da13311e78667001e673b2560", "fever.login", "jane.doe@example.com", $past, null], // expired
+ ["ab3b3eb8a13311e78667001e673b2560", "class.class", "jane.doe@example.com", null, null],
+ ["da772f8fa13c11e78667001e673b2560", "class.class", "john.doe@example.com", $future, null],
+ ["A", "miniflux.login", "jane.doe@example.com", null, "Label 1"],
+ ["B", "miniflux.login", "jane.doe@example.com", null, "Label 2"],
+ ["C", "miniflux.login", "john.doe@example.com", null, "Label 1"],
],
],
];
@@ -66,9 +71,6 @@ trait SeriesToken {
$this->assertArraySubset($exp1, Arsse::$db->tokenLookup("fever.login", "80fa94c1a11f11e78667001e673b2560"));
$this->assertArraySubset($exp2, Arsse::$db->tokenLookup("class.class", "da772f8fa13c11e78667001e673b2560"));
$this->assertArraySubset($exp3, Arsse::$db->tokenLookup("class.class", "ab3b3eb8a13311e78667001e673b2560"));
- // token lookup should not check authorization
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertArraySubset($exp1, Arsse::$db->tokenLookup("fever.login", "80fa94c1a11f11e78667001e673b2560"));
}
public function testLookUpAMissingToken(): void {
@@ -101,16 +103,10 @@ trait SeriesToken {
}
public function testCreateATokenForAMissingUser(): void {
- $this->assertException("doesNotExist", "User");
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
Arsse::$db->tokenCreate("fever.login", "jane.doe@example.biz");
}
- public function testCreateATokenWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tokenCreate("fever.login", "jane.doe@example.com");
- }
-
public function testRevokeAToken(): void {
$user = "jane.doe@example.com";
$id = "80fa94c1a11f11e78667001e673b2560";
@@ -136,9 +132,12 @@ trait SeriesToken {
$this->assertFalse(Arsse::$db->tokenRevoke($user, "unknown.class"));
}
- public function testRevokeATokenWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->tokenRevoke("jane.doe@example.com", "fever.login");
+ public function testListTokens(): void {
+ $user = "jane.doe@example.com";
+ $exp = [
+ ['id' => "A", 'data' => "Label 1"],
+ ['id' => "B", 'data' => "Label 2"],
+ ];
+ $this->assertResult($exp, Arsse::$db->tokenList($user, "miniflux.login"));
}
}
diff --git a/tests/cases/Database/SeriesUser.php b/tests/cases/Database/SeriesUser.php
index 54376600..031e5161 100644
--- a/tests/cases/Database/SeriesUser.php
+++ b/tests/cases/Database/SeriesUser.php
@@ -15,11 +15,29 @@ trait SeriesUser {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
+ 'admin' => 'bool',
],
'rows' => [
- ["admin@example.net", '$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW'], // password is hash of "secret"
- ["jane.doe@example.com", ""],
- ["john.doe@example.com", ""],
+ ["admin@example.net", '$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW', 1, 1], // password is hash of "secret"
+ ["jane.doe@example.com", "", 2, 0],
+ ["john.doe@example.com", "", 3, 0],
+ ],
+ ],
+ 'arsse_user_meta' => [
+ 'columns' => [
+ 'owner' => "str",
+ 'key' => "str",
+ 'value' => "str",
+ ],
+ 'rows' => [
+ ["admin@example.net", "lang", "en"],
+ ["admin@example.net", "tz", "America/Toronto"],
+ ["admin@example.net", "sort_asc", "0"],
+ ["jane.doe@example.com", "lang", "fr"],
+ ["jane.doe@example.com", "tz", "Asia/Kuala_Lumpur"],
+ ["jane.doe@example.com", "sort_asc", "1"],
+ ["john.doe@example.com", "stylesheet", "body {background:lightgray}"],
],
],
];
@@ -32,83 +50,47 @@ trait SeriesUser {
public function testCheckThatAUserExists(): void {
$this->assertTrue(Arsse::$db->userExists("jane.doe@example.com"));
$this->assertFalse(Arsse::$db->userExists("jane.doe@example.org"));
- \Phake::verify(Arsse::$user)->authorize("jane.doe@example.com", "userExists");
- \Phake::verify(Arsse::$user)->authorize("jane.doe@example.org", "userExists");
$this->compareExpectations(static::$drv, $this->data);
}
- public function testCheckThatAUserExistsWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->userExists("jane.doe@example.com");
- }
-
public function testGetAPassword(): void {
$hash = Arsse::$db->userPasswordGet("admin@example.net");
$this->assertSame('$2y$10$PbcG2ZR3Z8TuPzM7aHTF8.v61dtCjzjK78gdZJcp4UePE8T9jEgBW', $hash);
- \Phake::verify(Arsse::$user)->authorize("admin@example.net", "userPasswordGet");
$this->assertTrue(password_verify("secret", $hash));
}
public function testGetThePasswordOfAMissingUser(): void {
- $this->assertException("doesNotExist", "User");
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
Arsse::$db->userPasswordGet("john.doe@example.org");
}
- public function testGetAPasswordWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->userPasswordGet("admin@example.net");
- }
-
public function testAddANewUser(): void {
$this->assertTrue(Arsse::$db->userAdd("john.doe@example.org", ""));
- \Phake::verify(Arsse::$user)->authorize("john.doe@example.org", "userAdd");
$state = $this->primeExpectations($this->data, ['arsse_users' => ['id']]);
$state['arsse_users']['rows'][] = ["john.doe@example.org"];
$this->compareExpectations(static::$drv, $state);
}
public function testAddAnExistingUser(): void {
- $this->assertException("alreadyExists", "User");
+ $this->assertException("alreadyExists", "User", "ExceptionConflict");
Arsse::$db->userAdd("john.doe@example.com", "");
}
- public function testAddANewUserWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->userAdd("john.doe@example.org", "");
- }
-
public function testRemoveAUser(): void {
$this->assertTrue(Arsse::$db->userRemove("admin@example.net"));
- \Phake::verify(Arsse::$user)->authorize("admin@example.net", "userRemove");
$state = $this->primeExpectations($this->data, ['arsse_users' => ['id']]);
array_shift($state['arsse_users']['rows']);
$this->compareExpectations(static::$drv, $state);
}
public function testRemoveAMissingUser(): void {
- $this->assertException("doesNotExist", "User");
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
Arsse::$db->userRemove("john.doe@example.org");
}
- public function testRemoveAUserWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->userRemove("admin@example.net");
- }
-
public function testListAllUsers(): void {
$users = ["admin@example.net", "jane.doe@example.com", "john.doe@example.com"];
$this->assertSame($users, Arsse::$db->userList());
- \Phake::verify(Arsse::$user)->authorize("", "userList");
- }
-
- public function testListAllUsersWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->userList();
}
/**
@@ -121,7 +103,6 @@ trait SeriesUser {
$this->assertTrue(Arsse::$db->userPasswordSet($user, $pass));
$hash = Arsse::$db->userPasswordGet($user);
$this->assertNotEquals("", $hash);
- \Phake::verify(Arsse::$user)->authorize($user, "userPasswordSet");
$this->assertTrue(password_verify($pass, $hash), "Failed verifying password of $user '$pass' against hash '$hash'.");
}
@@ -133,13 +114,102 @@ trait SeriesUser {
}
public function testSetThePasswordOfAMissingUser(): void {
- $this->assertException("doesNotExist", "User");
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
Arsse::$db->userPasswordSet("john.doe@example.org", "secret");
}
- public function testSetAPasswordWithoutAuthority(): void {
- \Phake::when(Arsse::$user)->authorize->thenReturn(false);
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- Arsse::$db->userPasswordSet("john.doe@example.com", "secret");
+ /** @dataProvider provideMetaData */
+ public function testGetMetadata(string $user, bool $includeLarge, array $exp): void {
+ $this->assertSame($exp, Arsse::$db->userPropertiesGet($user, $includeLarge));
+ }
+
+ public function provideMetadata(): iterable {
+ return [
+ ["admin@example.net", true, ['num' => 1, 'admin' => 1, 'lang' => "en", 'sort_asc' => "0", 'tz' => "America/Toronto"]],
+ ["jane.doe@example.com", true, ['num' => 2, 'admin' => 0, 'lang' => "fr", 'sort_asc' => "1", 'tz' => "Asia/Kuala_Lumpur"]],
+ ["john.doe@example.com", true, ['num' => 3, 'admin' => 0, 'stylesheet' => "body {background:lightgray}"]],
+ ["admin@example.net", false, ['num' => 1, 'admin' => 1, 'lang' => "en", 'sort_asc' => "0", 'tz' => "America/Toronto"]],
+ ["jane.doe@example.com", false, ['num' => 2, 'admin' => 0, 'lang' => "fr", 'sort_asc' => "1", 'tz' => "Asia/Kuala_Lumpur"]],
+ ["john.doe@example.com", false, ['num' => 3, 'admin' => 0]],
+ ];
+ }
+
+ public function testGetTheMetadataOfAMissingUser(): void {
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ Arsse::$db->userPropertiesGet("john.doe@example.org");
+ }
+
+ public function testSetMetadata(): void {
+ $in = [
+ 'admin' => true,
+ 'lang' => "en-ca",
+ 'tz' => "Atlantic/Reykjavik",
+ 'sort_asc' => true,
+ ];
+ $this->assertTrue(Arsse::$db->userPropertiesSet("john.doe@example.com", $in));
+ $state = $this->primeExpectations($this->data, ['arsse_users' => ['id', 'num', 'admin'], 'arsse_user_meta' => ["owner", "key", "value"]]);
+ $state['arsse_users']['rows'][2][2] = 1;
+ $state['arsse_user_meta']['rows'][] = ["john.doe@example.com", "lang", "en-ca"];
+ $state['arsse_user_meta']['rows'][] = ["john.doe@example.com", "tz", "Atlantic/Reykjavik"];
+ $state['arsse_user_meta']['rows'][] = ["john.doe@example.com", "sort_asc", "1"];
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testSetNoMetadata(): void {
+ $in = [
+ 'num' => 2112,
+ 'stylesheet' => "body {background:lightgray}",
+ ];
+ $this->assertTrue(Arsse::$db->userPropertiesSet("john.doe@example.com", $in));
+ $state = $this->primeExpectations($this->data, ['arsse_users' => ['id', 'num', 'admin'], 'arsse_user_meta' => ["owner", "key", "value"]]);
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testSetTheMetadataOfAMissingUser(): void {
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ Arsse::$db->userPropertiesSet("john.doe@example.org", ['admin' => true]);
+ }
+
+ public function testLookUpAUserByNumber(): void {
+ $this->assertSame("admin@example.net", Arsse::$db->userLookup(1));
+ $this->assertSame("jane.doe@example.com", Arsse::$db->userLookup(2));
+ $this->assertSame("john.doe@example.com", Arsse::$db->userLookup(3));
+ }
+
+ public function testLookUpAMissingUserByNumber(): void {
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ Arsse::$db->userLookup(2112);
+ }
+
+ public function testRenameAUser(): void {
+ $this->assertTrue(Arsse::$db->userRename("john.doe@example.com", "juan.doe@example.com"));
+ $state = $this->primeExpectations($this->data, [
+ 'arsse_users' => ['id', 'num'],
+ 'arsse_user_meta' => ["owner", "key", "value"],
+ ]);
+ $state['arsse_users']['rows'][2][0] = "juan.doe@example.com";
+ $state['arsse_user_meta']['rows'][6][0] = "juan.doe@example.com";
+ $this->compareExpectations(static::$drv, $state);
+ }
+
+ public function testRenameAUserToTheSameName(): void {
+ $this->assertFalse(Arsse::$db->userRename("john.doe@example.com", "john.doe@example.com"));
+ }
+
+ public function testRenameAMissingUser(): void {
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ Arsse::$db->userRename("juan.doe@example.com", "john.doe@example.com");
+ }
+
+ public function testRenameAUserToADuplicateName(): void {
+ $this->assertException("alreadyExists", "User", "ExceptionConflict");
+ Arsse::$db->userRename("john.doe@example.com", "jane.doe@example.com");
+ }
+
+ public function testAddFirstUser(): void {
+ // first truncate the users table
+ static::$drv->exec("DELETE FROM arsse_users");
+ // add a user; if the max of the num column is not properly coalesced, this will result in a constraint violation
+ $this->assertTrue(Arsse::$db->userAdd("john.doe@example.com", ""));
}
}
diff --git a/tests/cases/Database/TestDatabase.php b/tests/cases/Database/TestDatabase.php
index cfaed762..00838b3a 100644
--- a/tests/cases/Database/TestDatabase.php
+++ b/tests/cases/Database/TestDatabase.php
@@ -13,10 +13,10 @@ class TestDatabase extends \JKingWeb\Arsse\Test\AbstractTest {
protected $db = null;
public function setUp(): void {
- self::clearData();
+ parent::setUp();
self::setConf();
try {
- $this->db = \Phake::makeVisible(\Phake::partialMock(Database::class));
+ $this->db = new Database;
} catch (\JKingWeb\Arsse\Db\Exception $e) {
$this->markTestSkipped("SQLite 3 database driver not available");
}
@@ -24,14 +24,20 @@ class TestDatabase extends \JKingWeb\Arsse\Test\AbstractTest {
public function tearDown(): void {
$this->db = null;
- self::clearData();
+ parent::tearDown();
+ }
+
+ protected function invoke(string $method, ...$arg) {
+ $m = new \ReflectionMethod($this->db, $method);
+ $m->setAccessible(true);
+ return $m->invoke($this->db, ...$arg);
}
/** @dataProvider provideInClauses */
public function testGenerateInClause(string $clause, array $values, array $inV, string $inT): void {
$types = array_fill(0, sizeof($values), $inT);
$exp = [$clause, $types, $values];
- $this->assertSame($exp, $this->db->generateIn($inV, $inT));
+ $this->assertSame($exp, $this->invoke("generateIn", $inV, $inT));
}
public function provideInClauses(): iterable {
@@ -66,7 +72,7 @@ class TestDatabase extends \JKingWeb\Arsse\Test\AbstractTest {
// this is not an exhaustive test; integration tests already cover the ins and outs of the functionality
$types = array_fill(0, sizeof($values), "str");
$exp = [$clause, $types, $values];
- $this->assertSame($exp, $this->db->generateSearch($inV, $inC, $inAny));
+ $this->assertSame($exp, $this->invoke("generateSearch", $inV, $inC, $inAny));
}
public function provideSearchClauses(): iterable {
diff --git a/tests/cases/Db/BaseDriver.php b/tests/cases/Db/BaseDriver.php
index 94091ac5..89a26007 100644
--- a/tests/cases/Db/BaseDriver.php
+++ b/tests/cases/Db/BaseDriver.php
@@ -31,7 +31,7 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function setUp(): void {
- self::clearData();
+ parent::setUp();
self::setConf(static::$conf);
if (!static::$interface) {
$this->markTestSkipped(static::$implementation." database driver not available");
@@ -48,7 +48,7 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest {
public function tearDown(): void {
// deconstruct the driver
unset($this->drv);
- self::clearData();
+ parent::tearDown();
}
public static function tearDownAfterClass(): void {
@@ -57,7 +57,7 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest {
static::dbRaze(static::$interface);
}
static::$interface = null;
- self::clearData();
+ self::clearData(true);
}
protected function exec($q): bool {
@@ -90,13 +90,6 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertTrue($this->drv->charsetAcceptable());
}
- public function testTranslateAToken(): void {
- $this->assertRegExp("/^[a-z][a-z0-9]*$/i", $this->drv->sqlToken("greatest"));
- $this->assertRegExp("/^\"?[a-z][a-z0-9_\-]*\"?$/i", $this->drv->sqlToken("nocase"));
- $this->assertRegExp("/^[a-z][a-z0-9]*$/i", $this->drv->sqlToken("like"));
- $this->assertSame("distinct", $this->drv->sqlToken("distinct"));
- }
-
public function testExecAValidStatement(): void {
$this->assertTrue($this->drv->exec($this->create));
}
@@ -386,4 +379,22 @@ abstract class BaseDriver extends \JKingWeb\Arsse\Test\AbstractTest {
// this performs maintenance in the absence of tables; see BaseUpdate.php for another test with tables
$this->assertTrue($this->drv->maintenance());
}
+
+ public function testTranslateTokens(): void {
+ $greatest = $this->drv->sqlToken("GrEatESt");
+ $nocase = $this->drv->sqlToken("noCASE");
+ $like = $this->drv->sqlToken("liKe");
+ $integer = $this->drv->sqlToken("InTEGer");
+ $asc = $this->drv->sqlToken("asc");
+ $desc = $this->drv->sqlToken("desc");
+
+ $this->assertSame("NOT_A_TOKEN", $this->drv->sqlToken("NOT_A_TOKEN"));
+
+ $this->assertSame("Z", $this->drv->query("SELECT $greatest('Z', 'A')")->getValue());
+ $this->assertSame("Z", $this->drv->query("SELECT 'Z' collate $nocase")->getValue());
+ $this->assertSame("Z", $this->drv->query("SELECT 'Z' where 'Z' $like 'z'")->getValue());
+ $this->assertEquals(1, $this->drv->query("SELECT CAST((1=1) as $integer)")->getValue());
+ $this->assertEquals([null, 1, 2], array_column($this->drv->query("SELECT 1 as t union select null as t union select 2 as t order by t $asc")->getAll(), "t"));
+ $this->assertEquals([2, 1, null], array_column($this->drv->query("SELECT 1 as t union select null as t union select 2 as t order by t $desc")->getAll(), "t"));
+ }
}
diff --git a/tests/cases/Db/BaseResult.php b/tests/cases/Db/BaseResult.php
index 4d3d2c49..3cfc5bb7 100644
--- a/tests/cases/Db/BaseResult.php
+++ b/tests/cases/Db/BaseResult.php
@@ -10,6 +10,8 @@ use JKingWeb\Arsse\Db\Result;
abstract class BaseResult extends \JKingWeb\Arsse\Test\AbstractTest {
protected static $insertDefault = "INSERT INTO arsse_test default values";
+ protected static $selectBlob = "SELECT x'DEADBEEF' as \"blob\"";
+ protected static $selectNullBlob = "SELECT null as \"blob\"";
protected static $interface;
protected $resultClass;
@@ -23,7 +25,7 @@ abstract class BaseResult extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function setUp(): void {
- self::clearData();
+ parent::setUp();
self::setConf();
if (!static::$interface) {
$this->markTestSkipped(static::$implementation." database driver not available");
@@ -33,17 +35,13 @@ abstract class BaseResult extends \JKingWeb\Arsse\Test\AbstractTest {
$this->resultClass = static::$dbResultClass;
}
- public function tearDown(): void {
- self::clearData();
- }
-
public static function tearDownAfterClass(): void {
if (static::$interface) {
// completely clear the database
static::dbRaze(static::$interface);
}
static::$interface = null;
- self::clearData();
+ self::clearData(true);
}
public function testConstructResult(): void {
@@ -129,4 +127,27 @@ abstract class BaseResult extends \JKingWeb\Arsse\Test\AbstractTest {
$test = new $this->resultClass(...$this->makeResult("SELECT '2112' as album, '2112' as track union select 'Clockwork Angels' as album, 'The Wreckers' as track"));
$this->assertEquals($exp, $test->getAll());
}
+
+ public function testGetBlobRow(): void {
+ $exp = ['blob' => hex2bin("DEADBEEF")];
+ $test = new $this->resultClass(...$this->makeResult(static::$selectBlob));
+ $this->assertEquals($exp, $test->getRow());
+ }
+
+ public function testGetBlobValue(): void {
+ $exp = hex2bin("DEADBEEF");
+ $test = new $this->resultClass(...$this->makeResult(static::$selectBlob));
+ $this->assertEquals($exp, $test->getValue());
+ }
+
+ public function testGetNullBlobRow(): void {
+ $exp = ['blob' => null];
+ $test = new $this->resultClass(...$this->makeResult(static::$selectNullBlob));
+ $this->assertEquals($exp, $test->getRow());
+ }
+
+ public function testGetNullBlobValue(): void {
+ $test = new $this->resultClass(...$this->makeResult(static::$selectNullBlob));
+ $this->assertNull($test->getValue());
+ }
}
diff --git a/tests/cases/Db/BaseStatement.php b/tests/cases/Db/BaseStatement.php
index 206aed79..bb8630e7 100644
--- a/tests/cases/Db/BaseStatement.php
+++ b/tests/cases/Db/BaseStatement.php
@@ -23,7 +23,7 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function setUp(): void {
- self::clearData();
+ parent::setUp();
self::setConf();
if (!static::$interface) {
$this->markTestSkipped(static::$implementation." database driver not available");
@@ -33,17 +33,13 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest {
$this->statementClass = static::$dbStatementClass;
}
- public function tearDown(): void {
- self::clearData();
- }
-
public static function tearDownAfterClass(): void {
if (static::$interface) {
// completely clear the database
static::dbRaze(static::$interface);
}
static::$interface = null;
- self::clearData();
+ self::clearData(true);
}
public function testConstructStatement(): void {
@@ -57,7 +53,6 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest {
} else {
$query = "SELECT ($exp = ?) as pass";
}
- $typeStr = "'".str_replace("'", "''", $type)."'";
$s = new $this->statementClass(...$this->makeStatement($query));
$s->retype(...[$type]);
$act = $s->run(...[$value])->getValue();
@@ -66,15 +61,11 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideBinaryBindings */
public function testHandleBinaryData($value, string $type, string $exp): void {
- if (in_array(static::$implementation, ["PostgreSQL", "PDO PostgreSQL"])) {
- $this->markTestIncomplete("Correct handling of binary data with PostgreSQL is not currently implemented");
- }
if ($exp === "null") {
$query = "SELECT (? is null) as pass";
} else {
$query = "SELECT ($exp = ?) as pass";
}
- $typeStr = "'".str_replace("'", "''", $type)."'";
$s = new $this->statementClass(...$this->makeStatement($query));
$s->retype(...[$type]);
$act = $s->run(...[$value])->getValue();
@@ -297,13 +288,11 @@ abstract class BaseStatement extends \JKingWeb\Arsse\Test\AbstractTest {
'UTF-8 string as strict binary' => ["\u{e9}", "strict binary", "x'c3a9'"],
'Binary string as integer' => [chr(233).chr(233), "integer", "0"],
'Binary string as float' => [chr(233).chr(233), "float", "0.0"],
- 'Binary string as string' => [chr(233).chr(233), "string", "'".chr(233).chr(233)."'"],
'Binary string as binary' => [chr(233).chr(233), "binary", "x'e9e9'"],
'Binary string as datetime' => [chr(233).chr(233), "datetime", "null"],
'Binary string as boolean' => [chr(233).chr(233), "boolean", "1"],
'Binary string as strict integer' => [chr(233).chr(233), "strict integer", "0"],
'Binary string as strict float' => [chr(233).chr(233), "strict float", "0.0"],
- 'Binary string as strict string' => [chr(233).chr(233), "strict string", "'".chr(233).chr(233)."'"],
'Binary string as strict binary' => [chr(233).chr(233), "strict binary", "x'e9e9'"],
'Binary string as strict datetime' => [chr(233).chr(233), "strict datetime", "'0001-01-01 00:00:00'"],
'Binary string as strict boolean' => [chr(233).chr(233), "strict boolean", "1"],
diff --git a/tests/cases/Db/BaseUpdate.php b/tests/cases/Db/BaseUpdate.php
index d5415134..c2a7cc18 100644
--- a/tests/cases/Db/BaseUpdate.php
+++ b/tests/cases/Db/BaseUpdate.php
@@ -29,7 +29,7 @@ class BaseUpdate extends \JKingWeb\Arsse\Test\AbstractTest {
if (!static::$interface) {
$this->markTestSkipped(static::$implementation." database driver not available");
}
- self::clearData();
+ parent::setUp();
self::setConf();
// construct a fresh driver for each test
$this->drv = new static::$dbDriverClass;
@@ -46,7 +46,7 @@ class BaseUpdate extends \JKingWeb\Arsse\Test\AbstractTest {
// deconstruct the driver
unset($this->drv);
unset($this->path, $this->base, $this->vfs);
- self::clearData();
+ parent::tearDown();
}
public static function tearDownAfterClass(): void {
@@ -55,7 +55,7 @@ class BaseUpdate extends \JKingWeb\Arsse\Test\AbstractTest {
static::dbRaze(static::$interface);
}
static::$interface = null;
- self::clearData();
+ self::clearData(true);
}
public function testLoadMissingFile(): void {
@@ -134,4 +134,58 @@ class BaseUpdate extends \JKingWeb\Arsse\Test\AbstractTest {
$this->drv->schemaUpdate(Database::SCHEMA_VERSION);
$this->assertTrue($this->drv->maintenance());
}
+
+ public function testUpdateTo7(): void {
+ $this->drv->schemaUpdate(6);
+ $this->drv->exec(
+ <<drv->schemaUpdate(7);
+ $users = [
+ ['id' => "a", 'password' => "xyz", 'num' => 1],
+ ['id' => "b", 'password' => "abc", 'num' => 2],
+ ];
+ $folders = [
+ ['owner' => "a", 'name' => "1"],
+ ['owner' => "b", 'name' => "2"],
+ ];
+ $icons = [
+ ['id' => 1, 'url' => "http://example.com/icon"],
+ ['id' => 2, 'url' => "http://example.org/icon"],
+ ];
+ $feeds = [
+ ['url' => 'http://example.com/', 'icon' => 1],
+ ['url' => 'http://example.org/', 'icon' => 2],
+ ['url' => 'https://example.com/', 'icon' => 1],
+ ['url' => 'http://example.net/', 'icon' => null],
+ ];
+ $subs = [
+ ['id' => 1, 'scrape' => 1],
+ ['id' => 2, 'scrape' => 1],
+ ['id' => 3, 'scrape' => 0],
+ ['id' => 4, 'scrape' => 0],
+ ];
+ $this->assertEquals($users, $this->drv->query("SELECT id, password, num from arsse_users order by id")->getAll());
+ $this->assertEquals($folders, $this->drv->query("SELECT owner, name from arsse_folders order by owner")->getAll());
+ $this->assertEquals($icons, $this->drv->query("SELECT id, url from arsse_icons order by id")->getAll());
+ $this->assertEquals($feeds, $this->drv->query("SELECT url, icon from arsse_feeds order by id")->getAll());
+ $this->assertEquals($subs, $this->drv->query("SELECT id, scrape from arsse_subscriptions order by id")->getAll());
+ }
}
diff --git a/tests/cases/Db/MySQL/TestStatement.php b/tests/cases/Db/MySQL/TestStatement.php
index 76c7b819..59b0177f 100644
--- a/tests/cases/Db/MySQL/TestStatement.php
+++ b/tests/cases/Db/MySQL/TestStatement.php
@@ -23,7 +23,7 @@ class TestStatement extends \JKingWeb\Arsse\TestCase\Db\BaseStatement {
case "float":
return (substr($value, -2) === ".0") ? "'".substr($value, 0, strlen($value) - 2)."'" : "'$value'";
case "string":
- if (preg_match("<^char\((\d+)\)$>", $value, $match)) {
+ if (preg_match("<^char\((\d+)\)$>D", $value, $match)) {
return "'".\IntlChar::chr((int) $match[1])."'";
}
return $value;
diff --git a/tests/cases/Db/MySQLPDO/TestStatement.php b/tests/cases/Db/MySQLPDO/TestStatement.php
index a6d0706e..678300c1 100644
--- a/tests/cases/Db/MySQLPDO/TestStatement.php
+++ b/tests/cases/Db/MySQLPDO/TestStatement.php
@@ -24,7 +24,7 @@ class TestStatement extends \JKingWeb\Arsse\TestCase\Db\BaseStatement {
case "float":
return (substr($value, -2) === ".0") ? "'".substr($value, 0, strlen($value) - 2)."'" : "'$value'";
case "string":
- if (preg_match("<^char\((\d+)\)$>", $value, $match)) {
+ if (preg_match("<^char\((\d+)\)$>D", $value, $match)) {
return "'".\IntlChar::chr((int) $match[1])."'";
}
return $value;
diff --git a/tests/cases/Db/PostgreSQL/TestDriver.php b/tests/cases/Db/PostgreSQL/TestDriver.php
index 5e60915a..df81bc4f 100644
--- a/tests/cases/Db/PostgreSQL/TestDriver.php
+++ b/tests/cases/Db/PostgreSQL/TestDriver.php
@@ -9,7 +9,7 @@ namespace JKingWeb\Arsse\TestCase\Db\PostgreSQL;
/**
* @group slow
* @covers \JKingWeb\Arsse\Db\PostgreSQL\Driver
- * @covers \JKingWeb\Arsse\Db\PostgreSQL\Dispatch
+ * @covers \JKingWeb\Arsse\Db\PostgreSQL\Dispatch
* @covers \JKingWeb\Arsse\Db\SQLState */
class TestDriver extends \JKingWeb\Arsse\TestCase\Db\BaseDriver {
use \JKingWeb\Arsse\Test\DatabaseDrivers\PostgreSQL;
diff --git a/tests/cases/Db/PostgreSQL/TestResult.php b/tests/cases/Db/PostgreSQL/TestResult.php
index 0992962b..9a4413d8 100644
--- a/tests/cases/Db/PostgreSQL/TestResult.php
+++ b/tests/cases/Db/PostgreSQL/TestResult.php
@@ -15,6 +15,8 @@ class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult {
protected static $createMeta = "CREATE TABLE arsse_meta(key text primary key not null, value text)";
protected static $createTest = "CREATE TABLE arsse_test(id bigserial primary key)";
+ protected static $selectBlob = "SELECT '\\xDEADBEEF'::bytea as blob";
+ protected static $selectNullBlob = "SELECT null::bytea as blob";
protected function makeResult(string $q): array {
$set = pg_query(static::$interface, $q);
diff --git a/tests/cases/Db/PostgreSQL/TestStatement.php b/tests/cases/Db/PostgreSQL/TestStatement.php
index 7b44ec1c..c4ecefad 100644
--- a/tests/cases/Db/PostgreSQL/TestStatement.php
+++ b/tests/cases/Db/PostgreSQL/TestStatement.php
@@ -9,7 +9,7 @@ namespace JKingWeb\Arsse\TestCase\Db\PostgreSQL;
/**
* @group slow
* @covers \JKingWeb\Arsse\Db\PostgreSQL\Statement
- * @covers \JKingWeb\Arsse\Db\PostgreSQL\Dispatch
+ * @covers \JKingWeb\Arsse\Db\PostgreSQL\Dispatch
* @covers \JKingWeb\Arsse\Db\SQLState */
class TestStatement extends \JKingWeb\Arsse\TestCase\Db\BaseStatement {
use \JKingWeb\Arsse\Test\DatabaseDrivers\PostgreSQL;
@@ -23,10 +23,15 @@ class TestStatement extends \JKingWeb\Arsse\TestCase\Db\BaseStatement {
case "float":
return (substr($value, -2) === ".0") ? "'".substr($value, 0, strlen($value) - 2)."'" : "'$value'";
case "string":
- if (preg_match("<^char\((\d+)\)$>", $value, $match)) {
+ if (preg_match("<^char\((\d+)\)$>D", $value, $match)) {
return "U&'\\+".str_pad(dechex((int) $match[1]), 6, "0", \STR_PAD_LEFT)."'";
}
return $value;
+ case "binary":
+ if ($value[0] === "x") {
+ return "'\\x".substr($value, 2)."::bytea";
+ }
+ // no break;
default:
return $value;
}
diff --git a/tests/cases/Db/PostgreSQLPDO/TestResult.php b/tests/cases/Db/PostgreSQLPDO/TestResult.php
index aaf6bca2..b3d0cb33 100644
--- a/tests/cases/Db/PostgreSQLPDO/TestResult.php
+++ b/tests/cases/Db/PostgreSQLPDO/TestResult.php
@@ -8,13 +8,15 @@ namespace JKingWeb\Arsse\TestCase\Db\PostgreSQLPDO;
/**
* @group slow
- * @covers \JKingWeb\Arsse\Db\PDOResult
+ * @covers \JKingWeb\Arsse\Db\PostgreSQL\PDOResult
*/
class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult {
use \JKingWeb\Arsse\Test\DatabaseDrivers\PostgreSQLPDO;
protected static $createMeta = "CREATE TABLE arsse_meta(key text primary key not null, value text)";
protected static $createTest = "CREATE TABLE arsse_test(id bigserial primary key)";
+ protected static $selectBlob = "SELECT '\\xDEADBEEF'::bytea as blob";
+ protected static $selectNullBlob = "SELECT null::bytea as blob";
protected function makeResult(string $q): array {
$set = static::$interface->query($q);
diff --git a/tests/cases/Db/PostgreSQLPDO/TestStatement.php b/tests/cases/Db/PostgreSQLPDO/TestStatement.php
index 926df768..89bae7d9 100644
--- a/tests/cases/Db/PostgreSQLPDO/TestStatement.php
+++ b/tests/cases/Db/PostgreSQLPDO/TestStatement.php
@@ -23,10 +23,15 @@ class TestStatement extends \JKingWeb\Arsse\TestCase\Db\BaseStatement {
case "float":
return (substr($value, -2) === ".0") ? "'".substr($value, 0, strlen($value) - 2)."'" : "'$value'";
case "string":
- if (preg_match("<^char\((\d+)\)$>", $value, $match)) {
+ if (preg_match("<^char\((\d+)\)$>D", $value, $match)) {
return "U&'\\+".str_pad(dechex((int) $match[1]), 6, "0", \STR_PAD_LEFT)."'";
}
return $value;
+ case "binary":
+ if ($value[0] === "x") {
+ return "'\\x".substr($value, 2)."::bytea";
+ }
+ // no break;
default:
return $value;
}
diff --git a/tests/cases/Db/SQLite3/TestCreation.php b/tests/cases/Db/SQLite3/TestCreation.php
index 1a4eef91..cc4927df 100644
--- a/tests/cases/Db/SQLite3/TestCreation.php
+++ b/tests/cases/Db/SQLite3/TestCreation.php
@@ -22,7 +22,7 @@ class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest {
if (!Driver::requirementsMet()) {
$this->markTestSkipped("SQLite extension not loaded");
}
- self::clearData();
+ parent::setUp();
// test files
$this->files = [
// cannot create files
@@ -108,10 +108,6 @@ class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest {
self::setConf();
}
- public function tearDown(): void {
- self::clearData();
- }
-
public function testFailToCreateDatabase(): void {
Arsse::$conf->dbSQLite3File = $this->path."Cmain/arsse.db";
$this->assertException("fileUncreatable", "Db");
@@ -189,4 +185,17 @@ class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertException("fileCorrupt", "Db");
new Driver;
}
+
+ public function testSetFileMode(): void {
+ $f = tempnam(sys_get_temp_dir(), "arsse");
+ Arsse::$conf->dbSQLite3File = $f;
+ // delete the file PHP just created
+ unlink($f);
+ // recreate the file
+ new Driver;
+ // check the mode
+ clearstatcache();
+ $mode = base_convert((string) stat($f)['mode'], 10, 8);
+ $this->assertMatchesRegularExpression("/640$/", $mode);
+ }
}
diff --git a/tests/cases/Db/SQLite3/TestDriver.php b/tests/cases/Db/SQLite3/TestDriver.php
index 4c80cbad..b3eb3593 100644
--- a/tests/cases/Db/SQLite3/TestDriver.php
+++ b/tests/cases/Db/SQLite3/TestDriver.php
@@ -26,8 +26,10 @@ class TestDriver extends \JKingWeb\Arsse\TestCase\Db\BaseDriver {
}
public static function tearDownAfterClass(): void {
- static::$interface->close();
- static::$interface = null;
+ if (static::$interface) {
+ static::$interface->close();
+ static::$interface = null;
+ }
parent::tearDownAfterClass();
@unlink(static::$file);
static::$file = null;
diff --git a/tests/cases/Db/SQLite3/TestResult.php b/tests/cases/Db/SQLite3/TestResult.php
index d7f8c091..5a8d0cd6 100644
--- a/tests/cases/Db/SQLite3/TestResult.php
+++ b/tests/cases/Db/SQLite3/TestResult.php
@@ -16,8 +16,10 @@ class TestResult extends \JKingWeb\Arsse\TestCase\Db\BaseResult {
protected static $createTest = "CREATE TABLE arsse_test(id integer primary key)";
public static function tearDownAfterClass(): void {
- static::$interface->close();
- static::$interface = null;
+ if (static::$interface) {
+ static::$interface->close();
+ static::$interface = null;
+ }
parent::tearDownAfterClass();
}
diff --git a/tests/cases/Db/SQLite3/TestStatement.php b/tests/cases/Db/SQLite3/TestStatement.php
index 1af5be4c..f7b970f2 100644
--- a/tests/cases/Db/SQLite3/TestStatement.php
+++ b/tests/cases/Db/SQLite3/TestStatement.php
@@ -13,8 +13,10 @@ class TestStatement extends \JKingWeb\Arsse\TestCase\Db\BaseStatement {
use \JKingWeb\Arsse\Test\DatabaseDrivers\SQLite3;
public static function tearDownAfterClass(): void {
- static::$interface->close();
- static::$interface = null;
+ if (static::$interface) {
+ static::$interface->close();
+ static::$interface = null;
+ }
parent::tearDownAfterClass();
}
diff --git a/tests/cases/Db/SQLite3/TestUpdate.php b/tests/cases/Db/SQLite3/TestUpdate.php
index 94842e25..409f1091 100644
--- a/tests/cases/Db/SQLite3/TestUpdate.php
+++ b/tests/cases/Db/SQLite3/TestUpdate.php
@@ -16,8 +16,10 @@ class TestUpdate extends \JKingWeb\Arsse\TestCase\Db\BaseUpdate {
protected static $minimal2 = "pragma user_version=2";
public static function tearDownAfterClass(): void {
- static::$interface->close();
- static::$interface = null;
+ if (static::$interface) {
+ static::$interface->close();
+ static::$interface = null;
+ }
parent::tearDownAfterClass();
}
}
diff --git a/tests/cases/Db/SQLite3PDO/TestCreation.php b/tests/cases/Db/SQLite3PDO/TestCreation.php
index 3b28cd50..ea5e9a32 100644
--- a/tests/cases/Db/SQLite3PDO/TestCreation.php
+++ b/tests/cases/Db/SQLite3PDO/TestCreation.php
@@ -24,7 +24,7 @@ class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest {
if (!Driver::requirementsMet()) {
$this->markTestSkipped("PDO-SQLite extension not loaded");
}
- self::clearData();
+ parent::setUp();
// test files
$this->files = [
// cannot create files
@@ -110,10 +110,6 @@ class TestCreation extends \JKingWeb\Arsse\Test\AbstractTest {
self::setConf();
}
- public function tearDown(): void {
- self::clearData();
- }
-
public function testFailToCreateDatabase(): void {
Arsse::$conf->dbSQLite3File = $this->path."Cmain/arsse.db";
$this->assertException("fileUncreatable", "Db");
diff --git a/tests/cases/Db/TestTransaction.php b/tests/cases/Db/TestTransaction.php
index 6b8aed58..6a850509 100644
--- a/tests/cases/Db/TestTransaction.php
+++ b/tests/cases/Db/TestTransaction.php
@@ -15,47 +15,50 @@ class TestTransaction extends \JKingWeb\Arsse\Test\AbstractTest {
protected $drv;
public function setUp(): void {
- self::clearData();
- $drv = \Phake::mock(\JKingWeb\Arsse\Db\SQLite3\Driver::class);
- \Phake::when($drv)->savepointRelease->thenReturn(true);
- \Phake::when($drv)->savepointUndo->thenReturn(true);
- \Phake::when($drv)->savepointCreate->thenReturn(1)->thenReturn(2);
+ parent::setUp();
+ $drv = $this->mock(\JKingWeb\Arsse\Db\SQLite3\Driver::class);
+ $drv->savepointRelease->returns(true);
+ $drv->savepointUndo->returns(true);
+ $drv->savepointCreate->returns(1, 2);
$this->drv = $drv;
}
public function testManipulateTransactions(): void {
- $tr1 = new Transaction($this->drv);
- $tr2 = new Transaction($this->drv);
- \Phake::verify($this->drv, \Phake::times(2))->savepointCreate;
+ $drv = $this->drv->get();
+ $tr1 = new Transaction($drv);
+ $tr2 = new Transaction($drv);
+ $this->drv->savepointCreate->twice()->called();
$this->assertSame(1, $tr1->getIndex());
$this->assertSame(2, $tr2->getIndex());
unset($tr1);
- \Phake::verify($this->drv)->savepointUndo(1);
+ $this->drv->savepointUndo->calledWith(1);
unset($tr2);
- \Phake::verify($this->drv)->savepointUndo(2);
+ $this->drv->savepointUndo->calledWith(2);
}
public function testCloseTransactions(): void {
- $tr1 = new Transaction($this->drv);
- $tr2 = new Transaction($this->drv);
+ $drv = $this->drv->get();
+ $tr1 = new Transaction($drv);
+ $tr2 = new Transaction($drv);
$this->assertTrue($tr1->isPending());
$this->assertTrue($tr2->isPending());
$tr1->commit();
$this->assertFalse($tr1->isPending());
$this->assertTrue($tr2->isPending());
- \Phake::verify($this->drv)->savepointRelease(1);
+ $this->drv->savepointRelease->calledWith(1);
$tr2->rollback();
$this->assertFalse($tr1->isPending());
$this->assertFalse($tr2->isPending());
- \Phake::verify($this->drv)->savepointUndo(2);
+ $this->drv->savepointUndo->calledWith(2);
}
public function testIgnoreRollbackErrors(): void {
- \Phake::when($this->drv)->savepointUndo->thenThrow(new Exception("savepointStale"));
- $tr1 = new Transaction($this->drv);
- $tr2 = new Transaction($this->drv);
+ $this->drv->savepointUndo->throws(new Exception("savepointStale"));
+ $drv = $this->drv->get();
+ $tr1 = new Transaction($drv);
+ $tr2 = new Transaction($drv);
unset($tr1, $tr2); // no exception should bubble up
- \Phake::verify($this->drv)->savepointUndo(1);
- \Phake::verify($this->drv)->savepointUndo(2);
+ $this->drv->savepointUndo->calledWith(1);
+ $this->drv->savepointUndo->calledWith(2);
}
}
diff --git a/tests/cases/Exception/TestException.php b/tests/cases/Exception/TestException.php
index 66a1f3a8..c85bff8d 100644
--- a/tests/cases/Exception/TestException.php
+++ b/tests/cases/Exception/TestException.php
@@ -16,16 +16,9 @@ class TestException extends \JKingWeb\Arsse\Test\AbstractTest {
public function setUp(): void {
self::clearData(false);
// create a mock Lang object so as not to create a dependency loop
- Arsse::$lang = \Phake::mock(Lang::class);
- \Phake::when(Arsse::$lang)->msg->thenReturn("");
- }
-
- public function tearDown(): void {
- // verify calls to the mock Lang object
- \Phake::verify(Arsse::$lang, \Phake::atLeast(0))->msg($this->isType("string"), $this->anything());
- \Phake::verifyNoOtherInteractions(Arsse::$lang);
- // clean up
- self::clearData(true);
+ $this->langMock = $this->mock(Lang::class);
+ $this->langMock->msg->returns("");
+ Arsse::$lang = $this->langMock->get();
}
public function testBaseClass(): void {
@@ -78,4 +71,14 @@ class TestException extends \JKingWeb\Arsse\Test\AbstractTest {
$this->expectException('JKingWeb\Arsse\ExceptionFatal');
throw new \JKingWeb\Arsse\ExceptionFatal("");
}
+
+ public function testGetExceptionSymbol(): void {
+ $e = new LangException("stringMissing", ['msgID' => "OOK"]);
+ $this->assertSame("stringMissing", $e->getSymbol());
+ }
+
+ public function testGetExceptionParams(): void {
+ $e = new LangException("stringMissing", ['msgID' => "OOK"]);
+ $this->assertSame(['msgID' => "OOK"], $e->getParams());
+ }
}
diff --git a/tests/cases/Feed/TestException.php b/tests/cases/Feed/TestException.php
index 95adde1d..b28d0d1d 100644
--- a/tests/cases/Feed/TestException.php
+++ b/tests/cases/Feed/TestException.php
@@ -20,7 +20,7 @@ class TestException extends \JKingWeb\Arsse\Test\AbstractTest {
public function testHandleCurlErrors(int $code, string $message): void {
$e = $this->mockGuzzleException(TransferException::class, "cURL error $code: Some message", 0);
$this->assertException($message, "Feed");
- throw new FeedException("https://example.com/", $e);
+ throw new FeedException("", ['url' => "https://example.com/"], $e);
}
public function provideCurlErrors() {
@@ -119,7 +119,7 @@ class TestException extends \JKingWeb\Arsse\Test\AbstractTest {
public function testHandleHttpErrors(int $code, string $message): void {
$e = $this->mockGuzzleException(BadResponseException::class, "Irrelevant message", $code);
$this->assertException($message, "Feed");
- throw new FeedException("https://example.com/", $e);
+ throw new FeedException("", ['url' => "https://example.com/"], $e);
}
public function provideHTTPErrors() {
@@ -145,7 +145,7 @@ class TestException extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider providePicoFeedException */
public function testHandlePicofeedException(PicoFeedException $e, string $message) {
$this->assertException($message, "Feed");
- throw new FeedException("https://example.com/", $e);
+ throw new FeedException("", ['url' => "https://example.com/"], $e);
}
public function providePicoFeedException() {
@@ -160,18 +160,18 @@ class TestException extends \JKingWeb\Arsse\Test\AbstractTest {
public function testHandleExcessRedirections() {
$e = $this->mockGuzzleException(TooManyRedirectsException::class, "Irrelevant message", 404);
$this->assertException("maxRedirect", "Feed");
- throw new FeedException("https://example.com/", $e);
+ throw new FeedException("", ['url' => "https://example.com/"], $e);
}
public function testHandleGenericStreamErrors() {
$e = $this->mockGuzzleException(TransferException::class, "Error creating resource: Irrelevant message", 403);
$this->assertException("transmissionError", "Feed");
- throw new FeedException("https://example.com/", $e);
+ throw new FeedException("", ['url' => "https://example.com/"], $e);
}
public function testHandleUnexpectedError() {
$e = new \Exception;
$this->assertException("internalError", "Feed");
- throw new FeedException("https://example.com/", $e);
+ throw new FeedException("", ['url' => "https://example.com/"], $e);
}
}
diff --git a/tests/cases/Feed/TestFeed.php b/tests/cases/Feed/TestFeed.php
index 01f4f5f1..857c42e7 100644
--- a/tests/cases/Feed/TestFeed.php
+++ b/tests/cases/Feed/TestFeed.php
@@ -11,6 +11,7 @@ use JKingWeb\Arsse\Feed;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Test\Result;
+use Eloquent\Phony\Phpunit\Phony;
/**
* @covers \JKingWeb\Arsse\Feed
@@ -92,9 +93,15 @@ class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest {
$this->markTestSkipped("Test Web server is not accepting requests");
}
$this->base = self::$host."Feed/";
- self::clearData();
+ parent::setUp();
self::setConf();
- Arsse::$db = \Phake::mock(Database::class);
+ $this->dbMock = $this->mock(Database::class);
+ $this->dbMock->feedMatchLatest->with(Phony::wildcard())->returns(new Result([]));
+ $this->dbMock->feedMatchLatest->with(1, Phony::any())->returns(new Result($this->latest));
+ $this->dbMock->feedMatchIds->with(Phony::wildcard())->returns(new Result([]));
+ $this->dbMock->feedMatchIds->with(1, Phony::wildcard())->returns(new Result($this->others));
+ $this->dbMock->feedRulesGet->returns([]);
+ Arsse::$db = $this->dbMock->get();
}
public function testParseAFeed(): void {
@@ -150,6 +157,27 @@ class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest {
Feed::discover($this->base."Discovery/Invalid");
}
+ public function testDiscoverAMissingFeed(): void {
+ $this->assertException("invalidUrl", "Feed");
+ Feed::discover($this->base."Discovery/Missing");
+ }
+
+ public function testDiscoverMultipleFeedsSuccessfully(): void {
+ $exp1 = [$this->base."Discovery/Feed", $this->base."Discovery/Missing"];
+ $exp2 = [$this->base."Discovery/Feed"];
+ $this->assertSame($exp1, Feed::discoverAll($this->base."Discovery/Valid"));
+ $this->assertSame($exp2, Feed::discoverAll($this->base."Discovery/Feed"));
+ }
+
+ public function testDiscoverMultipleFeedsUnsuccessfully(): void {
+ $this->assertSame([], Feed::discoverAll($this->base."Discovery/Invalid"));
+ }
+
+ public function testDiscoverMultipleMissingFeeds(): void {
+ $this->assertException("invalidUrl", "Feed");
+ Feed::discoverAll($this->base."Discovery/Missing");
+ }
+
public function testParseEntityExpansionAttack(): void {
$this->assertException("xmlEntity", "Feed");
new Feed(null, $this->base."Parsing/XEEAttack");
@@ -314,7 +342,10 @@ class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testMatchLatestArticles(): void {
- \Phake::when(Arsse::$db)->feedMatchLatest(1, $this->anything())->thenReturn(new Result($this->latest));
+ $this->dbMock = $this->mock(Database::class);
+ $this->dbMock->feedMatchLatest->with(Phony::wildcard())->returns(new Result([]));
+ $this->dbMock->feedMatchLatest->with(1, Phony::any())->returns(new Result($this->latest));
+ Arsse::$db = $this->dbMock->get();
$f = new Feed(1, $this->base."Matching/1");
$this->assertCount(0, $f->newItems);
$this->assertCount(0, $f->changedItems);
@@ -330,8 +361,6 @@ class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testMatchHistoricalArticles(): void {
- \Phake::when(Arsse::$db)->feedMatchLatest(1, $this->anything())->thenReturn(new Result($this->latest));
- \Phake::when(Arsse::$db)->feedMatchIds(1, $this->anything(), $this->anything(), $this->anything(), $this->anything())->thenReturn(new Result($this->others));
$f = new Feed(1, $this->base."Matching/5");
$this->assertCount(0, $f->newItems);
$this->assertCount(0, $f->changedItems);
@@ -345,6 +374,38 @@ class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest {
// now try to scrape and get different content
$f = new Feed(null, $this->base."Scraping/Feed", "", "", "", "", true);
$exp = "Partial content, followed by more content
";
+ $this->assertSame($exp, $f->newItems[0]->scrapedContent);
+ $exp = "Partial content
";
$this->assertSame($exp, $f->newItems[0]->content);
}
+
+ public function testFetchWithIcon(): void {
+ $d = base64_decode("R0lGODlhAQABAIABAAAAAP///yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==");
+ $f = new Feed(null, $this->base."WithIcon/GIF");
+ $this->assertSame(self::$host."Icon/GIF", $f->iconUrl);
+ $this->assertSame("image/gif", $f->iconType);
+ $this->assertSame($d, $f->iconData);
+ }
+
+ public function testApplyFilterRules(): void {
+ $exp = [
+ 'jack' => ['new' => [false, true, true, false, true], 'changed' => [7 => true, 47 => true, 2112 => false, 1 => true, 42 => false]],
+ 'sam' => ['new' => [false, true, false, false, false], 'changed' => [7 => false, 47 => true, 2112 => false, 1 => false, 42 => false]],
+ ];
+ $this->dbMock->feedMatchIds->returns(new Result([
+ // these are the sixth through tenth entries in the feed; the title hashes have been omitted for brevity
+ ['id' => 7, 'guid' => '0f2a218c311e3d8105f1b075142a5d26dabf056ffc61abe77e96c8f071bbf4a7', 'edited' => null, 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
+ ['id' => 47, 'guid' => '1c19e3b9018bc246b7414ae919ddebc88d0c575129e8c4a57b84b826c00f6db5', 'edited' => null, 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
+ ['id' => 2112, 'guid' => '964db0b9292ad0c7a6c225f2e0966f3bda53486fae65db0310c97409974e65b8', 'edited' => null, 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
+ ['id' => 1, 'guid' => '436070cda5713a0d9a8fdc8652c7ab142f0550697acfd5206a16c18aee355039', 'edited' => null, 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
+ ['id' => 42, 'guid' => '1a731433a1904220ef26e731ada7262e1d5bcecae53e7b5df9e1f5713af6e5d3', 'edited' => null, 'url_title_hash' => "", 'url_content_hash' => '', 'title_content_hash' => ''],
+ ]));
+ $this->dbMock->feedRulesGet->returns([
+ 'jack' => ['keep' => "", 'block' => '`A|W|J|S`u'],
+ 'sam' => ['keep' => "`B|T|X`u", 'block' => '`C`u'],
+ ]);
+ Arsse::$db = $this->dbMock->get();
+ $f = new Feed(5, $this->base."Filtering/1");
+ $this->assertSame($exp, $f->filteredItems);
+ }
}
diff --git a/tests/cases/Feed/TestFetching.php b/tests/cases/Feed/TestFetching.php
index 3ce3e8d2..011aab28 100644
--- a/tests/cases/Feed/TestFetching.php
+++ b/tests/cases/Feed/TestFetching.php
@@ -23,7 +23,7 @@ class TestFetching extends \JKingWeb\Arsse\Test\AbstractTest {
$this->markTestSkipped("Test Web server is not accepting requests");
}
$this->base = self::$host."Feed/";
- self::clearData();
+ parent::setUp();
self::setConf();
}
diff --git a/tests/cases/ImportExport/TestFile.php b/tests/cases/ImportExport/TestFile.php
index bbffca67..b5599cbd 100644
--- a/tests/cases/ImportExport/TestFile.php
+++ b/tests/cases/ImportExport/TestFile.php
@@ -17,11 +17,11 @@ class TestFile extends \JKingWeb\Arsse\Test\AbstractTest {
protected $proc;
public function setUp(): void {
- self::clearData();
+ parent::setUp();
// create a mock Import/Export processor with stubbed underlying import/export routines
- $this->proc = \Phake::partialMock(AbstractImportExport::class);
- \Phake::when($this->proc)->export->thenReturn("EXPORT_FILE");
- \Phake::when($this->proc)->import->thenReturn(true);
+ $this->proc = $this->partialMock(AbstractImportExport::class);
+ $this->proc->export->returns("EXPORT_FILE");
+ $this->proc->import->returns(true);
$this->vfs = vfsStream::setup("root", null, [
'exportGoodFile' => "",
'exportGoodDir' => [],
@@ -41,7 +41,7 @@ class TestFile extends \JKingWeb\Arsse\Test\AbstractTest {
$this->path = null;
$this->vfs = null;
$this->proc = null;
- self::clearData();
+ parent::tearDown();
}
/** @dataProvider provideFileExports */
@@ -50,13 +50,13 @@ class TestFile extends \JKingWeb\Arsse\Test\AbstractTest {
try {
if ($exp instanceof \JKingWeb\Arsse\AbstractException) {
$this->assertException($exp);
- $this->proc->exportFile($path, $user, $flat);
+ $this->proc->get()->exportFile($path, $user, $flat);
} else {
- $this->assertSame($exp, $this->proc->exportFile($path, $user, $flat));
+ $this->assertSame($exp, $this->proc->get()->exportFile($path, $user, $flat));
$this->assertSame("EXPORT_FILE", $this->vfs->getChild($file)->getContent());
}
} finally {
- \Phake::verify($this->proc)->export($user, $flat);
+ $this->proc->export->calledWith($user, $flat);
}
}
@@ -89,12 +89,12 @@ class TestFile extends \JKingWeb\Arsse\Test\AbstractTest {
try {
if ($exp instanceof \JKingWeb\Arsse\AbstractException) {
$this->assertException($exp);
- $this->proc->importFile($path, $user, $flat, $replace);
+ $this->proc->get()->importFile($path, $user, $flat, $replace);
} else {
- $this->assertSame($exp, $this->proc->importFile($path, $user, $flat, $replace));
+ $this->assertSame($exp, $this->proc->get()->importFile($path, $user, $flat, $replace));
}
} finally {
- \Phake::verify($this->proc, \Phake::times((int) ($exp === true)))->import($user, "GOOD_FILE", $flat, $replace);
+ $this->proc->import->times((int) ($exp === true))->calledWith($user, "GOOD_FILE", $flat, $replace);
}
}
diff --git a/tests/cases/ImportExport/TestImportExport.php b/tests/cases/ImportExport/TestImportExport.php
index 4d3fef30..ae0c7a4a 100644
--- a/tests/cases/ImportExport/TestImportExport.php
+++ b/tests/cases/ImportExport/TestImportExport.php
@@ -24,13 +24,11 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest {
];
public function setUp(): void {
- self::clearData();
+ parent::setUp();
// create a mock user manager
- Arsse::$user = \Phake::mock(\JKingWeb\Arsse\User::class);
- \Phake::when(Arsse::$user)->exists->thenReturn(true);
- \Phake::when(Arsse::$user)->authorize->thenReturn(true);
+ Arsse::$user = $this->mock(\JKingWeb\Arsse\User::class)->get();
// create a mock Import/Export processor
- $this->proc = \Phake::partialMock(AbstractImportExport::class);
+ $this->proc = $this->partialMock(AbstractImportExport::class);
// initialize an SQLite memeory database
static::setConf();
try {
@@ -46,10 +44,11 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest {
'columns' => [
'id' => 'str',
'password' => 'str',
+ 'num' => 'int',
],
'rows' => [
- ["john.doe@example.com", ""],
- ["jane.doe@example.com", ""],
+ ["john.doe@example.com", "", 1],
+ ["jane.doe@example.com", "", 2],
],
],
'arsse_folders' => [
@@ -143,13 +142,12 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest {
public function tearDown(): void {
$this->drv = null;
$this->proc = null;
- self::clearData();
+ parent::tearDown();
}
public function testImportForAMissingUser(): void {
- \Phake::when(Arsse::$user)->exists->thenReturn(false);
- $this->assertException("doesNotExist", "User");
- $this->proc->import("john.doe@example.com", "", false, false);
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ $this->proc->get()->import("no.one@example.com", "", false, false);
}
public function testImportWithInvalidFolder(): void {
@@ -157,9 +155,9 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest {
], [1 =>
['id' => 1, 'name' => "", 'parent' => 0],
]];
- \Phake::when($this->proc)->parse->thenReturn($in);
+ $this->proc->parse->returns($in);
$this->assertException("invalidFolderName", "ImportExport");
- $this->proc->import("john.doe@example.com", "", false, false);
+ $this->proc->get()->import("john.doe@example.com", "", false, false);
}
public function testImportWithDuplicateFolder(): void {
@@ -168,9 +166,9 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest {
['id' => 1, 'name' => "New", 'parent' => 0],
['id' => 2, 'name' => "New", 'parent' => 0],
]];
- \Phake::when($this->proc)->parse->thenReturn($in);
+ $this->proc->parse->returns($in);
$this->assertException("invalidFolderCopy", "ImportExport");
- $this->proc->import("john.doe@example.com", "", false, false);
+ $this->proc->get()->import("john.doe@example.com", "", false, false);
}
public function testMakeNoEffectiveChanges(): void {
@@ -189,11 +187,11 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest {
['id' => 5, 'name' => "Local", 'parent' => 4],
['id' => 6, 'name' => "National", 'parent' => 4],
]];
- \Phake::when($this->proc)->parse->thenReturn($in);
+ $this->proc->parse->returns($in);
$exp = $this->primeExpectations($this->data, $this->checkTables);
- $this->proc->import("john.doe@example.com", "", false, false);
+ $this->proc->get()->import("john.doe@example.com", "", false, false);
$this->compareExpectations($this->drv, $exp);
- $this->proc->import("john.doe@example.com", "", false, true);
+ $this->proc->get()->import("john.doe@example.com", "", false, true);
$this->compareExpectations($this->drv, $exp);
}
@@ -214,8 +212,8 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest {
['id' => 6, 'name' => "National", 'parent' => 4],
['id' => 7, 'name' => "Nature", 'parent' => 0], // new folder
]];
- \Phake::when($this->proc)->parse->thenReturn($in);
- $this->proc->import("john.doe@example.com", "", false, true);
+ $this->proc->parse->returns($in);
+ $this->proc->get()->import("john.doe@example.com", "", false, true);
$exp = $this->primeExpectations($this->data, $this->checkTables);
$exp['arsse_subscriptions']['rows'][3] = [4, "john.doe@example.com", null, 4, "CBC"];
$exp['arsse_folders']['rows'][] = [7, "john.doe@example.com", null, "Nature"];
@@ -226,8 +224,8 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest {
$in = [[
['url' => "http://localhost:8000/Import/some-feed", 'title' => "Some Feed", 'folder' => 0, 'tags' => ["frequent", "cryptic"]], //one existing tag and one new one
], []];
- \Phake::when($this->proc)->parse->thenReturn($in);
- $this->proc->import("john.doe@example.com", "", false, false);
+ $this->proc->parse->returns($in);
+ $this->proc->get()->import("john.doe@example.com", "", false, false);
$exp = $this->primeExpectations($this->data, $this->checkTables);
$exp['arsse_feeds']['rows'][] = [7, "http://localhost:8000/Import/some-feed", "Some feed"]; // author-supplied and user-supplied titles differ
$exp['arsse_subscriptions']['rows'][] = [7, "john.doe@example.com", null, 7, "Some Feed"];
@@ -241,9 +239,9 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest {
$in = [[
['url' => "http://localhost:8000/Import/some-feed", 'title' => "Some Feed", 'folder' => 0, 'tags' => [""]],
], []];
- \Phake::when($this->proc)->parse->thenReturn($in);
+ $this->proc->parse->returns($in);
$this->assertException("invalidTagName", "ImportExport");
- $this->proc->import("john.doe@example.com", "", false, false);
+ $this->proc->get()->import("john.doe@example.com", "", false, false);
}
public function testReplaceData(): void {
@@ -252,8 +250,8 @@ class TestImportExport extends \JKingWeb\Arsse\Test\AbstractTest {
], [1 =>
['id' => 1, 'name' => "Photography", 'parent' => 0],
]];
- \Phake::when($this->proc)->parse->thenReturn($in);
- $this->proc->import("john.doe@example.com", "", false, true);
+ $this->proc->parse->returns($in);
+ $this->proc->get()->import("john.doe@example.com", "", false, true);
$exp = $this->primeExpectations($this->data, $this->checkTables);
$exp['arsse_feeds']['rows'][] = [7, "http://localhost:8000/Import/some-feed", "Some feed"]; // author-supplied and user-supplied titles differ
$exp['arsse_subscriptions']['rows'] = [[7, "john.doe@example.com", 4, 7, "Some Feed"]];
diff --git a/tests/cases/ImportExport/TestOPML.php b/tests/cases/ImportExport/TestOPML.php
index 1e65fa1e..7e73bc30 100644
--- a/tests/cases/ImportExport/TestOPML.php
+++ b/tests/cases/ImportExport/TestOPML.php
@@ -7,6 +7,7 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\ImportExport;
use JKingWeb\Arsse\Arsse;
+use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Test\Result;
use JKingWeb\Arsse\ImportExport\OPML;
use JKingWeb\Arsse\ImportExport\Exception;
@@ -22,12 +23,12 @@ class TestOPML extends \JKingWeb\Arsse\Test\AbstractTest {
['id' => 1, 'parent' => null, 'children' => 1, 'feeds' => 1, 'name' => "Science"],
];
protected $subscriptions = [
- ['id' => 3, 'folder' => 1, 'top_folder' => 1, 'unread' => 2, 'updated' => "2016-05-23 06:40:02", 'err_msg' => 'argh', 'title' => 'Ars Technica', 'url' => "http://example.com/3", 'favicon' => 'http://example.com/3.png'],
- ['id' => 4, 'folder' => 6, 'top_folder' => 3, 'unread' => 6, 'updated' => "2017-10-09 15:58:34", 'err_msg' => '', 'title' => 'CBC News', 'url' => "http://example.com/4", 'favicon' => 'http://example.com/4.png'],
- ['id' => 6, 'folder' => null, 'top_folder' => null, 'unread' => 0, 'updated' => "2010-02-12 20:08:47", 'err_msg' => '', 'title' => 'Eurogamer', 'url' => "http://example.com/6", 'favicon' => 'http://example.com/6.png'],
- ['id' => 1, 'folder' => 2, 'top_folder' => 1, 'unread' => 5, 'updated' => "2017-09-15 22:54:16", 'err_msg' => '', 'title' => 'NASA JPL', 'url' => "http://example.com/1", 'favicon' => null],
- ['id' => 5, 'folder' => 6, 'top_folder' => 3, 'unread' => 12, 'updated' => "2017-07-07 17:07:17", 'err_msg' => '', 'title' => 'Ottawa Citizen', 'url' => "http://example.com/5", 'favicon' => ''],
- ['id' => 2, 'folder' => 5, 'top_folder' => 3, 'unread' => 10, 'updated' => "2011-11-11 11:11:11", 'err_msg' => 'oops', 'title' => 'Toronto Star', 'url' => "http://example.com/2", 'favicon' => 'http://example.com/2.png'],
+ ['id' => 3, 'folder' => 1, 'top_folder' => 1, 'unread' => 2, 'updated' => "2016-05-23 06:40:02", 'err_msg' => 'argh', 'title' => 'Ars Technica', 'url' => "http://localhost:8000/3", 'icon_url' => 'http://localhost:8000/3.png'],
+ ['id' => 4, 'folder' => 6, 'top_folder' => 3, 'unread' => 6, 'updated' => "2017-10-09 15:58:34", 'err_msg' => '', 'title' => 'CBC News', 'url' => "http://localhost:8000/4", 'icon_url' => 'http://localhost:8000/4.png'],
+ ['id' => 6, 'folder' => null, 'top_folder' => null, 'unread' => 0, 'updated' => "2010-02-12 20:08:47", 'err_msg' => '', 'title' => 'Eurogamer', 'url' => "http://localhost:8000/6", 'icon_url' => 'http://localhost:8000/6.png'],
+ ['id' => 1, 'folder' => 2, 'top_folder' => 1, 'unread' => 5, 'updated' => "2017-09-15 22:54:16", 'err_msg' => '', 'title' => 'NASA JPL', 'url' => "http://localhost:8000/1", 'icon_url' => null],
+ ['id' => 5, 'folder' => 6, 'top_folder' => 3, 'unread' => 12, 'updated' => "2017-07-07 17:07:17", 'err_msg' => '', 'title' => 'Ottawa Citizen', 'url' => "http://localhost:8000/5", 'icon_url' => ''],
+ ['id' => 2, 'folder' => 5, 'top_folder' => 3, 'unread' => 10, 'updated' => "2011-11-11 11:11:11", 'err_msg' => 'oops', 'title' => 'Toronto Star', 'url' => "http://localhost:8000/2", 'icon_url' => 'http://localhost:8000/2.png'],
];
protected $tags = [
['id' => 1, 'name' => "Canada", 'subscription' => 2],
@@ -47,20 +48,20 @@ class TestOPML extends \JKingWeb\Arsse\Test\AbstractTest {
-
+
-
-
+
+
-
+
-
+
-
+
-
-
-
-
-
-
+
+
+
+
+
+
OPML_EXPORT_SERIALIZATION;
public function setUp(): void {
- self::clearData();
- Arsse::$db = \Phake::mock(\JKingWeb\Arsse\Database::class);
- Arsse::$user = \Phake::mock(\JKingWeb\Arsse\User::class);
- \Phake::when(Arsse::$user)->exists->thenReturn(true);
+ parent::setUp();
+ $this->dbMock = $this->mock(Database::class);
+ $this->dbMock->userExists->returns(true);
+ $this->dbMock->folderList->with("john.doe@example.com")->returns(new Result($this->folders));
+ $this->dbMock->subscriptionList->with("john.doe@example.com")->returns(new Result($this->subscriptions));
+ $this->dbMock->tagSummarize->with("john.doe@example.com")->returns(new Result($this->tags));
+ Arsse::$db = $this->dbMock->get();
}
public function testExportToOpml(): void {
- \Phake::when(Arsse::$db)->folderList("john.doe@example.com")->thenReturn(new Result($this->folders));
- \Phake::when(Arsse::$db)->subscriptionList("john.doe@example.com")->thenReturn(new Result($this->subscriptions));
- \Phake::when(Arsse::$db)->tagSummarize("john.doe@example.com")->thenReturn(new Result($this->tags));
$this->assertXmlStringEqualsXmlString($this->serialization, (new OPML)->export("john.doe@example.com"));
}
public function testExportToFlatOpml(): void {
- \Phake::when(Arsse::$db)->folderList("john.doe@example.com")->thenReturn(new Result($this->folders));
- \Phake::when(Arsse::$db)->subscriptionList("john.doe@example.com")->thenReturn(new Result($this->subscriptions));
- \Phake::when(Arsse::$db)->tagSummarize("john.doe@example.com")->thenReturn(new Result($this->tags));
$this->assertXmlStringEqualsXmlString($this->serializationFlat, (new OPML)->export("john.doe@example.com", true));
}
public function testExportToOpmlAMissingUser(): void {
- \Phake::when(Arsse::$user)->exists->thenReturn(false);
- $this->assertException("doesNotExist", "User");
+ $this->dbMock->userExists->returns(false);
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
(new OPML)->export("john.doe@example.com");
}
/** @dataProvider provideParserData */
public function testParseOpmlForImport(string $file, bool $flat, $exp): void {
$data = file_get_contents(\JKingWeb\Arsse\DOCROOT."Import/OPML/$file");
- // set up a partial mock to make the ImportExport::parse() method visible
- $parser = \Phake::makeVisible(\Phake::partialMock(OPML::class));
- if ($exp instanceof \JKingWeb\Arsse\AbstractException) {
+ // make the ImportExport::parse() method visible
+ $parser = new OPML;
+ $parseFunc = new \ReflectionMethod($parser, "parse");
+ $parseFunc->setAccessible(true);
+ if ($exp instanceof \Exception) {
$this->assertException($exp);
- $parser->parse($data, $flat);
+ $parseFunc->invoke($parser, $data, $flat);
} else {
- $this->assertSame($exp, $parser->parse($data, $flat));
+ $this->assertSame($exp, $parseFunc->invoke($parser, $data, $flat));
}
}
@@ -130,10 +130,10 @@ OPML_EXPORT_SERIALIZATION;
["Empty.2.opml", false, [[], []]],
["Empty.3.opml", false, [[], []]],
["FeedsOnly.opml", false, [[
- ['url' => "http://example.com/1", 'title' => "Feed 1", 'folder' => 0, 'tags' => []],
- ['url' => "http://example.com/2", 'title' => "", 'folder' => 0, 'tags' => []],
- ['url' => "http://example.com/3", 'title' => "", 'folder' => 0, 'tags' => []],
- ['url' => "http://example.com/4", 'title' => "", 'folder' => 0, 'tags' => []],
+ ['url' => "http://localhost:8000/1", 'title' => "Feed 1", 'folder' => 0, 'tags' => []],
+ ['url' => "http://localhost:8000/2", 'title' => "", 'folder' => 0, 'tags' => []],
+ ['url' => "http://localhost:8000/3", 'title' => "", 'folder' => 0, 'tags' => []],
+ ['url' => "http://localhost:8000/4", 'title' => "", 'folder' => 0, 'tags' => []],
['url' => "", 'title' => "", 'folder' => 0, 'tags' => ["whee"]],
['url' => "", 'title' => "", 'folder' => 0, 'tags' => ["whee", "whoo"]],
], []]],
diff --git a/tests/cases/Misc/TestContext.php b/tests/cases/Misc/TestContext.php
index 037ca8e1..46ecaaff 100644
--- a/tests/cases/Misc/TestContext.php
+++ b/tests/cases/Misc/TestContext.php
@@ -46,6 +46,7 @@ class TestContext extends \JKingWeb\Arsse\Test\AbstractTest {
'oldestEdition' => 1337,
'unread' => true,
'starred' => true,
+ 'hidden' => true,
'modifiedSince' => new \DateTime(),
'notModifiedSince' => new \DateTime(),
'markedSince' => new \DateTime(),
diff --git a/tests/cases/Misc/TestDate.php b/tests/cases/Misc/TestDate.php
index 5c16d352..e0cbbade 100644
--- a/tests/cases/Misc/TestDate.php
+++ b/tests/cases/Misc/TestDate.php
@@ -10,10 +10,6 @@ use JKingWeb\Arsse\Misc\Date;
/** @covers \JKingWeb\Arsse\Misc\Date */
class TestDate extends \JKingWeb\Arsse\Test\AbstractTest {
- public function setUp(): void {
- self::clearData();
- }
-
public function testNormalizeADate(): void {
$exp = new \DateTimeImmutable("2018-01-01T00:00:00Z");
$this->assertEquals($exp, Date::normalize(1514764800));
diff --git a/tests/cases/Misc/TestFactory.php b/tests/cases/Misc/TestFactory.php
new file mode 100644
index 00000000..c6940193
--- /dev/null
+++ b/tests/cases/Misc/TestFactory.php
@@ -0,0 +1,17 @@
+assertInstanceOf(\stdClass::class, $f->get(\stdClass::class));
+ }
+}
diff --git a/tests/cases/Misc/TestRule.php b/tests/cases/Misc/TestRule.php
new file mode 100644
index 00000000..88503292
--- /dev/null
+++ b/tests/cases/Misc/TestRule.php
@@ -0,0 +1,51 @@
+assertTrue(Rule::validate("`..`..\\`..\\\\`.."));
+ $this->assertSame($exp, Rule::prep("`..`..\\`..\\\\`.."));
+ }
+
+ public function testPrepareAnInvalidPattern(): void {
+ $this->assertFalse(Rule::validate("["));
+ $this->assertException("invalidPattern", "Rule");
+ Rule::prep("[");
+ }
+
+ public function testPrepareAnEmptyPattern(): void {
+ $this->assertTrue(Rule::validate(""));
+ $this->assertSame("", Rule::prep(""));
+ }
+
+ /** @dataProvider provideApplications */
+ public function testApplyRules(string $keepRule, string $blockRule, string $title, array $categories, $exp): void {
+ $keepRule = Rule::prep($keepRule);
+ $blockRule = Rule::prep($blockRule);
+ $this->assertSame($exp, Rule::apply($keepRule, $blockRule, $title, $categories));
+ }
+
+ public function provideApplications(): iterable {
+ return [
+ ["", "", "Title", ["Dummy", "Category"], true],
+ ["^Title$", "", "Title", ["Dummy", "Category"], true],
+ ["^Category$", "", "Title", ["Dummy", "Category"], true],
+ ["^Naught$", "", "Title", ["Dummy", "Category"], false],
+ ["", "^Title$", "Title", ["Dummy", "Category"], false],
+ ["", "^Category$", "Title", ["Dummy", "Category"], false],
+ ["", "^Naught$", "Title", ["Dummy", "Category"], true],
+ ["^Category$", "^Category$", "Title", ["Dummy", "Category"], false],
+ ["", "^A B C$", "A B\nC", ["X\n Y \t \r Z"], false],
+ ["", "^X Y Z$", "A B\nC", ["X\n Y \t \r Z"], false],
+ ];
+ }
+}
diff --git a/tests/cases/Misc/TestURL.php b/tests/cases/Misc/TestURL.php
index 1b6fff96..1d86c9ca 100644
--- a/tests/cases/Misc/TestURL.php
+++ b/tests/cases/Misc/TestURL.php
@@ -10,9 +10,6 @@ use JKingWeb\Arsse\Misc\URL;
/** @covers \JKingWeb\Arsse\Misc\URL */
class TestURL extends \JKingWeb\Arsse\Test\AbstractTest {
- public function setUp(): void {
- self::clearData();
- }
/** @dataProvider provideNormalizations */
public function testNormalizeAUrl(string $url, string $exp, string $user = null, string $pass = null): void {
diff --git a/tests/cases/Misc/TestValueInfo.php b/tests/cases/Misc/TestValueInfo.php
index efa1becf..7b30e117 100644
--- a/tests/cases/Misc/TestValueInfo.php
+++ b/tests/cases/Misc/TestValueInfo.php
@@ -12,10 +12,6 @@ use JKingWeb\Arsse\Test\Result;
/** @covers \JKingWeb\Arsse\Misc\ValueInfo */
class TestValueInfo extends \JKingWeb\Arsse\Test\AbstractTest {
- public function setUp(): void {
- self::clearData();
- }
-
public function testGetIntegerInfo(): void {
$tests = [
[null, I::NULL],
diff --git a/tests/cases/REST/Fever/TestAPI.php b/tests/cases/REST/Fever/TestAPI.php
index d0632c97..a9896c73 100644
--- a/tests/cases/REST/Fever/TestAPI.php
+++ b/tests/cases/REST/Fever/TestAPI.php
@@ -15,7 +15,6 @@ use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Db\Transaction;
use JKingWeb\Arsse\REST\Fever\API;
use Psr\Http\Message\ResponseInterface;
-use Laminas\Diactoros\ServerRequest;
use Laminas\Diactoros\Response\JsonResponse;
use Laminas\Diactoros\Response\XmlResponse;
use Laminas\Diactoros\Response\EmptyResponse;
@@ -24,7 +23,8 @@ use Laminas\Diactoros\Response\EmptyResponse;
class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
/** @var \JKingWeb\Arsse\REST\Fever\API */
protected $h;
-
+ protected $hMock;
+ protected $userId = "john.doe@example.com";
protected $articles = [
'db' => [
[
@@ -141,35 +141,35 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
],
],
];
+
protected function v($value) {
return $value;
}
- protected function req($dataGet, $dataPost = "", string $method = "POST", string $type = null, string $target = "", string $user = null): ServerRequest {
+ protected function req($dataGet, $dataPost = "", string $method = "POST", ?string $type = null, string $target = "", ?string $user = null): ResponseInterface {
+ Arsse::$db = $this->dbMock->get();
+ $this->h = $this->hMock->get();
$prefix = "/fever/";
$url = $prefix.$target;
$type = $type ?? "application/x-www-form-urlencoded";
- return $this->serverRequest($method, $url, $prefix, [], [], $dataPost, $type, $dataGet, $user);
+ return $this->h->dispatch($this->serverRequest($method, $url, $prefix, [], [], $dataPost, $type, $dataGet, $user));
}
public function setUp(): void {
self::clearData();
self::setConf();
// create a mock user manager
- Arsse::$user = \Phake::mock(User::class);
- \Phake::when(Arsse::$user)->auth->thenReturn(true);
- Arsse::$user->id = "john.doe@example.com";
+ $this->userMock = $this->mock(User::class);
+ $this->userMock->auth->returns(true);
+ Arsse::$user = $this->userMock->get();
+ Arsse::$user->id = $this->userId;
// create a mock database interface
- Arsse::$db = \Phake::mock(Database::class);
- \Phake::when(Arsse::$db)->begin->thenReturn(\Phake::mock(Transaction::class));
- \Phake::when(Arsse::$db)->tokenLookup->thenReturn(['user' => "john.doe@example.com"]);
+ $this->dbMock = $this->mock(Database::class);
+ $this->dbMock->begin->returns($this->mock(Transaction::class));
+ $this->dbMock->tokenLookup->returns(['user' => "john.doe@example.com"]);
// instantiate the handler as a partial mock to simplify testing
- $this->h = \Phake::partialMock(API::class);
- \Phake::when($this->h)->baseResponse->thenReturn([]);
- }
-
- public function tearDown(): void {
- self::clearData();
+ $this->hMock = $this->partialMock(API::class);
+ $this->hMock->baseResponse->returns([]);
}
/** @dataProvider provideTokenAuthenticationRequests */
@@ -179,17 +179,16 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
'userSessionEnforced' => $tokenEnforced,
], true);
Arsse::$user->id = null;
- \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("subjectMissing"));
- \Phake::when(Arsse::$db)->tokenLookup("fever.login", "validtoken")->thenReturn(['user' => "jane.doe@example.com"]);
+ $this->dbMock->tokenLookup->throws(new ExceptionInput("subjectMissing"));
+ $this->dbMock->tokenLookup->with("fever.login", "validtoken")->returns(['user' => "jane.doe@example.com"]);
// test only the authentication process
- \Phake::when($this->h)->baseResponse->thenReturnCallback(function(bool $authenticated) {
+ $this->hMock->baseResponse->does(function(bool $authenticated) {
return ['auth' => (int) $authenticated];
});
- \Phake::when($this->h)->processRequest->thenReturnCallback(function($out, $G, $P) {
+ $this->hMock->processRequest->does(function($out, $G, $P) {
return $out;
});
- $act = $this->h->dispatch($this->req($dataGet, $dataPost, "POST", null, "", $httpUser));
- $this->assertMessage($exp, $act);
+ $this->assertMessage($exp, $this->req($dataGet, $dataPost, "POST", null, "", $httpUser));
}
public function provideTokenAuthenticationRequests(): iterable {
@@ -245,12 +244,12 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testListGroups(): void {
- \Phake::when(Arsse::$db)->tagList(Arsse::$user->id)->thenReturn(new Result([
+ $this->dbMock->tagList->with($this->userId)->returns(new Result([
['id' => 1, 'name' => "Fascinating", 'subscriptions' => 2],
['id' => 2, 'name' => "Interesting", 'subscriptions' => 2],
['id' => 3, 'name' => "Boring", 'subscriptions' => 0],
]));
- \Phake::when(Arsse::$db)->tagSummarize(Arsse::$user->id)->thenReturn(new Result([
+ $this->dbMock->tagSummarize->with($this->userId)->returns(new Result([
['id' => 1, 'name' => "Fascinating", 'subscription' => 1],
['id' => 1, 'name' => "Fascinating", 'subscription' => 2],
['id' => 2, 'name' => "Interesting", 'subscription' => 1],
@@ -267,17 +266,16 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
['group_id' => 2, 'feed_ids' => "1,3"],
],
]);
- $act = $this->h->dispatch($this->req("api&groups"));
- $this->assertMessage($exp, $act);
+ $this->assertMessage($exp, $this->req("api&groups"));
}
public function testListFeeds(): void {
- \Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result([
- ['id' => 1, 'feed' => 5, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'source' => "http://example.com/", 'edited' => "2019-01-01 21:12:00", 'favicon' => "http://example.com/favicon.ico"],
- ['id' => 2, 'feed' => 9, 'title' => "Ook, Ook Eek Ook!", 'url' => "http://example.net/feed", 'source' => "http://example.net/", 'edited' => "1988-06-24 12:21:00", 'favicon' => ""],
- ['id' => 3, 'feed' => 1, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'source' => "http://example.org/", 'edited' => "1991-08-12 03:22:00", 'favicon' => "http://example.org/favicon.ico"],
+ $this->dbMock->subscriptionList->with($this->userId)->returns(new Result([
+ ['id' => 1, 'feed' => 5, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'source' => "http://example.com/", 'edited' => "2019-01-01 21:12:00", 'icon_url' => "http://example.com/favicon.ico", 'icon_id' => 42],
+ ['id' => 2, 'feed' => 9, 'title' => "Ook, Ook Eek Ook!", 'url' => "http://example.net/feed", 'source' => "http://example.net/", 'edited' => "1988-06-24 12:21:00", 'icon_url' => "", 'icon_id' => null],
+ ['id' => 3, 'feed' => 1, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'source' => "http://example.org/", 'edited' => "1991-08-12 03:22:00", 'icon_url' => "http://example.org/favicon.ico", 'icon_id' => 42],
]));
- \Phake::when(Arsse::$db)->tagSummarize(Arsse::$user->id)->thenReturn(new Result([
+ $this->dbMock->tagSummarize->with($this->userId)->returns(new Result([
['id' => 1, 'name' => "Fascinating", 'subscription' => 1],
['id' => 1, 'name' => "Fascinating", 'subscription' => 2],
['id' => 2, 'name' => "Interesting", 'subscription' => 1],
@@ -285,82 +283,79 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
]));
$exp = new JsonResponse([
'feeds' => [
- ['id' => 1, 'favicon_id' => 0, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'site_url' => "http://example.com/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("2019-01-01T21:12:00Z")],
- ['id' => 2, 'favicon_id' => 0, 'title' => "Ook, Ook Eek Ook!", 'url' => "http://example.net/feed", 'site_url' => "http://example.net/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1988-06-24T12:21:00Z")],
- ['id' => 3, 'favicon_id' => 0, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'site_url' => "http://example.org/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1991-08-12T03:22:00Z")],
+ ['id' => 1, 'favicon_id' => 42, 'title' => "Ankh-Morpork News", 'url' => "http://example.com/feed", 'site_url' => "http://example.com/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("2019-01-01T21:12:00Z")],
+ ['id' => 2, 'favicon_id' => 0, 'title' => "Ook, Ook Eek Ook!", 'url' => "http://example.net/feed", 'site_url' => "http://example.net/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1988-06-24T12:21:00Z")],
+ ['id' => 3, 'favicon_id' => 42, 'title' => "The Last Soul", 'url' => "http://example.org/feed", 'site_url' => "http://example.org/", 'is_spark' => 0, 'last_updated_on_time' => strtotime("1991-08-12T03:22:00Z")],
],
'feeds_groups' => [
['group_id' => 1, 'feed_ids' => "1,2"],
['group_id' => 2, 'feed_ids' => "1,3"],
],
]);
- $act = $this->h->dispatch($this->req("api&feeds"));
- $this->assertMessage($exp, $act);
+ $this->assertMessage($exp, $this->req("api&feeds"));
}
/** @dataProvider provideItemListContexts */
public function testListItems(string $url, Context $c, bool $desc): void {
$fields = ["id", "subscription", "title", "author", "content", "url", "starred", "unread", "published_date"];
$order = [$desc ? "id desc" : "id"];
- \Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->articles['db']));
- \Phake::when(Arsse::$db)->articleCount(Arsse::$user->id)->thenReturn(1024);
+ $this->dbMock->articleList->returns(new Result($this->articles['db']));
+ $this->dbMock->articleCount->with($this->userId, (new Context)->hidden(false))->returns(1024);
$exp = new JsonResponse([
'items' => $this->articles['rest'],
'total_items' => 1024,
]);
- $act = $this->h->dispatch($this->req("api&$url"));
- $this->assertMessage($exp, $act);
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $c, $fields, $order);
+ $this->assertMessage($exp, $this->req("api&$url"));
+ $this->dbMock->articleList->calledWith($this->userId, $this->equalTo($c), $fields, $order);
}
public function provideItemListContexts(): iterable {
$c = (new Context)->limit(50);
return [
- ["items", (clone $c), false],
- ["items&group_ids=1,2,3,4", (clone $c)->tags([1,2,3,4]), false],
- ["items&feed_ids=1,2,3,4", (clone $c)->subscriptions([1,2,3,4]), false],
+ ["items", (clone $c)->hidden(false), false],
+ ["items&group_ids=1,2,3,4", (clone $c)->tags([1,2,3,4])->hidden(false), false],
+ ["items&feed_ids=1,2,3,4", (clone $c)->subscriptions([1,2,3,4])->hidden(false), false],
["items&with_ids=1,2,3,4", (clone $c)->articles([1,2,3,4]), false],
- ["items&since_id=1", (clone $c)->oldestArticle(2), false],
- ["items&max_id=2", (clone $c)->latestArticle(1), true],
+ ["items&since_id=1", (clone $c)->oldestArticle(2)->hidden(false), false],
+ ["items&max_id=2", (clone $c)->latestArticle(1)->hidden(false), true],
["items&with_ids=1,2,3,4&max_id=6", (clone $c)->articles([1,2,3,4]), false],
["items&with_ids=1,2,3,4&since_id=6", (clone $c)->articles([1,2,3,4]), false],
- ["items&max_id=3&since_id=6", (clone $c)->latestArticle(2), true],
- ["items&feed_ids=1,2,3,4&since_id=6", (clone $c)->subscriptions([1,2,3,4])->oldestArticle(7), false],
+ ["items&max_id=3&since_id=6", (clone $c)->latestArticle(2)->hidden(false), true],
+ ["items&feed_ids=1,2,3,4&since_id=6", (clone $c)->subscriptions([1,2,3,4])->oldestArticle(7)->hidden(false), false],
];
}
public function testListItemIds(): void {
$saved = [['id' => 1],['id' => 2],['id' => 3]];
$unread = [['id' => 4],['id' => 5],['id' => 6]];
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true))->thenReturn(new Result($saved));
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread));
+ $this->dbMock->articleList->with($this->userId, (new Context)->starred(true)->hidden(false))->returns(new Result($saved));
+ $this->dbMock->articleList->with($this->userId, (new Context)->unread(true)->hidden(false))->returns(new Result($unread));
$exp = new JsonResponse(['saved_item_ids' => "1,2,3"]);
- $this->assertMessage($exp, $this->h->dispatch($this->req("api&saved_item_ids")));
+ $this->assertMessage($exp, $this->req("api&saved_item_ids"));
$exp = new JsonResponse(['unread_item_ids' => "4,5,6"]);
- $this->assertMessage($exp, $this->h->dispatch($this->req("api&unread_item_ids")));
+ $this->assertMessage($exp, $this->req("api&unread_item_ids"));
}
public function testListHotLinks(): void {
// hot links are not actually implemented, so an empty array should be all we get
$exp = new JsonResponse(['links' => []]);
- $this->assertMessage($exp, $this->h->dispatch($this->req("api&links")));
+ $this->assertMessage($exp, $this->req("api&links"));
}
/** @dataProvider provideMarkingContexts */
public function testSetMarks(string $post, Context $c, array $data, array $out): void {
$saved = [['id' => 1],['id' => 2],['id' => 3]];
$unread = [['id' => 4],['id' => 5],['id' => 6]];
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true))->thenReturn(new Result($saved));
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread));
- \Phake::when(Arsse::$db)->articleMark->thenReturn(0);
- \Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->article(2112))->thenThrow(new \JKingWeb\Arsse\Db\ExceptionInput("subjectMissing"));
+ $this->dbMock->articleList->with($this->userId, (new Context)->starred(true)->hidden(false))->returns(new Result($saved));
+ $this->dbMock->articleList->with($this->userId, (new Context)->unread(true)->hidden(false))->returns(new Result($unread));
+ $this->dbMock->articleMark->returns(0);
+ $this->dbMock->articleMark->with($this->userId, $this->anything(), (new Context)->article(2112))->throws(new \JKingWeb\Arsse\Db\ExceptionInput("subjectMissing"));
$exp = new JsonResponse($out);
- $act = $this->h->dispatch($this->req("api", $post));
- $this->assertMessage($exp, $act);
+ $this->assertMessage($exp, $this->req("api", $post));
if ($c && $data) {
- \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $data, $c);
+ $this->dbMock->articleMark->calledWith($this->userId, $data, $this->equalTo($c));
} else {
- \Phake::verify(Arsse::$db, \Phake::times(0))->articleMark;
+ $this->dbMock->articleMark->never()->called();
}
}
@@ -368,17 +363,16 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
public function testSetMarksWithQuery(string $get, Context $c, array $data, array $out): void {
$saved = [['id' => 1],['id' => 2],['id' => 3]];
$unread = [['id' => 4],['id' => 5],['id' => 6]];
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true))->thenReturn(new Result($saved));
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread));
- \Phake::when(Arsse::$db)->articleMark->thenReturn(0);
- \Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $this->anything(), (new Context)->article(2112))->thenThrow(new \JKingWeb\Arsse\Db\ExceptionInput("subjectMissing"));
+ $this->dbMock->articleList->with($this->userId, (new Context)->starred(true)->hidden(false))->returns(new Result($saved));
+ $this->dbMock->articleList->with($this->userId, (new Context)->unread(true)->hidden(false))->returns(new Result($unread));
+ $this->dbMock->articleMark->returns(0);
+ $this->dbMock->articleMark->with($this->userId, $this->anything(), (new Context)->article(2112))->throws(new \JKingWeb\Arsse\Db\ExceptionInput("subjectMissing"));
$exp = new JsonResponse($out);
- $act = $this->h->dispatch($this->req("api&$get"));
- $this->assertMessage($exp, $act);
+ $this->assertMessage($exp, $this->req("api&$get"));
if ($c && $data) {
- \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $data, $c);
+ $this->dbMock->articleMark->calledWith($this->userId, $data, $this->equalTo($c));
} else {
- \Phake::verify(Arsse::$db, \Phake::times(0))->articleMark;
+ $this->dbMock->articleMark->never()->called();
}
}
@@ -395,20 +389,20 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
["mark=item&as=read&id=2112", (new Context)->article(2112), $markRead, $listUnread], // article doesn't exist
["mark=item&as=saved&id=5", (new Context)->article(5), $markSaved, $listSaved],
["mark=item&as=unsaved&id=42", (new Context)->article(42), $markUnsaved, $listSaved],
- ["mark=feed&as=read&id=5", (new Context)->subscription(5), $markRead, $listUnread],
- ["mark=feed&as=unread&id=42", (new Context)->subscription(42), $markUnread, $listUnread],
- ["mark=feed&as=saved&id=5", (new Context)->subscription(5), $markSaved, $listSaved],
- ["mark=feed&as=unsaved&id=42", (new Context)->subscription(42), $markUnsaved, $listSaved],
- ["mark=group&as=read&id=5", (new Context)->tag(5), $markRead, $listUnread],
- ["mark=group&as=unread&id=42", (new Context)->tag(42), $markUnread, $listUnread],
- ["mark=group&as=saved&id=5", (new Context)->tag(5), $markSaved, $listSaved],
- ["mark=group&as=unsaved&id=42", (new Context)->tag(42), $markUnsaved, $listSaved],
+ ["mark=feed&as=read&id=5", (new Context)->subscription(5)->hidden(false), $markRead, $listUnread],
+ ["mark=feed&as=unread&id=42", (new Context)->subscription(42)->hidden(false), $markUnread, $listUnread],
+ ["mark=feed&as=saved&id=5", (new Context)->subscription(5)->hidden(false), $markSaved, $listSaved],
+ ["mark=feed&as=unsaved&id=42", (new Context)->subscription(42)->hidden(false), $markUnsaved, $listSaved],
+ ["mark=group&as=read&id=5", (new Context)->tag(5)->hidden(false), $markRead, $listUnread],
+ ["mark=group&as=unread&id=42", (new Context)->tag(42)->hidden(false), $markUnread, $listUnread],
+ ["mark=group&as=saved&id=5", (new Context)->tag(5)->hidden(false), $markSaved, $listSaved],
+ ["mark=group&as=unsaved&id=42", (new Context)->tag(42)->hidden(false), $markUnsaved, $listSaved],
["mark=item&as=invalid&id=42", new Context, [], []],
["mark=invalid&as=unread&id=42", new Context, [], []],
- ["mark=group&as=read&id=0", (new Context), $markRead, $listUnread],
- ["mark=group&as=unread&id=0", (new Context), $markUnread, $listUnread],
- ["mark=group&as=saved&id=0", (new Context), $markSaved, $listSaved],
- ["mark=group&as=unsaved&id=0", (new Context), $markUnsaved, $listSaved],
+ ["mark=group&as=read&id=0", (new Context)->hidden(false), $markRead, $listUnread],
+ ["mark=group&as=unread&id=0", (new Context)->hidden(false), $markUnread, $listUnread],
+ ["mark=group&as=saved&id=0", (new Context)->hidden(false), $markSaved, $listSaved],
+ ["mark=group&as=unsaved&id=0", (new Context)->hidden(false), $markUnsaved, $listSaved],
["mark=group&as=read&id=-1", (new Context)->not->folder(0), $markRead, $listUnread],
["mark=group&as=unread&id=-1", (new Context)->not->folder(0), $markUnread, $listUnread],
["mark=group&as=saved&id=-1", (new Context)->not->folder(0), $markSaved, $listSaved],
@@ -421,88 +415,89 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
}
/** @dataProvider provideInvalidRequests */
- public function testSendInvalidRequests(ServerRequest $req, ResponseInterface $exp): void {
- $this->assertMessage($exp, $this->h->dispatch($req));
+ public function testSendInvalidRequests(string $get, string $post, string $method, ?string $type, ResponseInterface $exp): void {
+ $this->assertMessage($exp, $this->req($get, $post, $method, $type));
}
public function provideInvalidRequests(): iterable {
return [
- 'Not an API request' => [$this->req(""), new EmptyResponse(404)],
- 'Wrong method' => [$this->req("api", "", "PUT"), new EmptyResponse(405, ['Allow' => "OPTIONS,POST"])],
- 'Non-standard method' => [$this->req("api", "", "GET"), new JsonResponse([])],
- 'Wrong content type' => [$this->req("api", '{"api_key":"validToken"}', "POST", "application/json"), new EmptyResponse(415, ['Accept' => "application/x-www-form-urlencoded, multipart/form-data"])],
- 'Non-standard content type' => [$this->req("api", '{"api_key":"validToken"}', "POST", "multipart/form-data; boundary=33b68964f0de4c1f-5144aa6caaa6e4a8-18bfaf416a1786c8-5c5053a45f221bc1"), new JsonResponse([])],
+ 'Not an API request' => ["", "", "POST", null, new EmptyResponse(404)],
+ 'Wrong method' => ["api", "", "PUT", null, new EmptyResponse(405, ['Allow' => "OPTIONS,POST"])],
+ 'Non-standard method' => ["api", "", "GET", null, new JsonResponse([])],
+ 'Wrong content type' => ["api", '{"api_key":"validToken"}', "POST", "application/json", new JsonResponse([])], // some clients send nonsensical content types; Fever seems to have allowed this
+ 'Non-standard content type' => ["api", '{"api_key":"validToken"}', "POST", "multipart/form-data; boundary=33b68964f0de4c1f-5144aa6caaa6e4a8-18bfaf416a1786c8-5c5053a45f221bc1", new JsonResponse([])], // some clients send nonsensical content types; Fever seems to have allowed this
];
}
public function testMakeABaseQuery(): void {
- $this->h = \Phake::partialMock(API::class);
- \Phake::when($this->h)->logIn->thenReturn(true);
- \Phake::when(Arsse::$db)->subscriptionRefreshed(Arsse::$user->id)->thenReturn(new \DateTimeImmutable("2000-01-01T00:00:00Z"));
+ $this->hMock->baseResponse->forwards();
+ $this->hMock->logIn->returns(true);
+ $this->dbMock->subscriptionRefreshed->with($this->userId)->returns(new \DateTimeImmutable("2000-01-01T00:00:00Z"));
$exp = new JsonResponse([
'api_version' => API::LEVEL,
'auth' => 1,
'last_refreshed_on_time' => 946684800,
]);
- $act = $this->h->dispatch($this->req("api"));
- $this->assertMessage($exp, $act);
- \Phake::when(Arsse::$db)->subscriptionRefreshed(Arsse::$user->id)->thenReturn(null); // no subscriptions
+ $this->assertMessage($exp, $this->req("api"));
+ $this->dbMock->subscriptionRefreshed->with($this->userId)->returns(null); // no subscriptions
$exp = new JsonResponse([
'api_version' => API::LEVEL,
'auth' => 1,
'last_refreshed_on_time' => null,
]);
- $act = $this->h->dispatch($this->req("api"));
- $this->assertMessage($exp, $act);
- \Phake::when($this->h)->logIn->thenReturn(false);
+ $this->assertMessage($exp, $this->req("api"));
+ $this->hMock->logIn->returns(false);
$exp = new JsonResponse([
'api_version' => API::LEVEL,
'auth' => 0,
]);
- $act = $this->h->dispatch($this->req("api"));
- $this->assertMessage($exp, $act);
+ $this->assertMessage($exp, $this->req("api"));
}
public function testUndoReadMarks(): void {
$unread = [['id' => 4],['id' => 5],['id' => 6]];
$out = ['unread_item_ids' => "4,5,6"];
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->thenReturn(new Result([['marked_date' => "2000-01-01 00:00:00"]]));
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true))->thenReturn(new Result($unread));
- \Phake::when(Arsse::$db)->articleMark->thenReturn(0);
+ $this->dbMock->articleList->with($this->userId, $this->equalTo((new Context)->limit(1)->hidden(false)), ["marked_date"], ["marked_date desc"])->returns(new Result([['marked_date' => "2000-01-01 00:00:00"]]));
+ $this->dbMock->articleList->with($this->userId, $this->equalTo((new Context)->unread(true)->hidden(false)))->returns(new Result($unread));
+ $this->dbMock->articleMark->returns(0);
$exp = new JsonResponse($out);
- $act = $this->h->dispatch($this->req("api", ['unread_recently_read' => 1]));
- $this->assertMessage($exp, $act);
- \Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, ['read' => false], (new Context)->unread(false)->markedSince("1999-12-31T23:59:45Z"));
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(1), ["marked_date"], ["marked_date desc"])->thenReturn(new Result([]));
- $act = $this->h->dispatch($this->req("api", ['unread_recently_read' => 1]));
- $this->assertMessage($exp, $act);
- \Phake::verify(Arsse::$db)->articleMark; // only called one time, above
+ $this->assertMessage($exp, $this->req("api", ['unread_recently_read' => 1]));
+ $this->dbMock->articleMark->calledWith($this->userId, ['read' => false], $this->equalTo((new Context)->unread(false)->markedSince("1999-12-31T23:59:45Z")->hidden(false)));
+ $this->dbMock->articleList->with($this->userId, (new Context)->limit(1)->hidden(false), ["marked_date"], ["marked_date desc"])->returns(new Result([]));
+ $this->assertMessage($exp, $this->req("api", ['unread_recently_read' => 1]));
+ $this->dbMock->articleMark->once()->called(); // only called one time, above
}
public function testOutputToXml(): void {
- \Phake::when($this->h)->processRequest->thenReturn([
+ $this->hMock->processRequest->returns([
'items' => $this->articles['rest'],
'total_items' => 1024,
]);
$exp = new XmlResponse("101 8 Article title 1 <p>Article content 1</p>http://example.com/1 0 0 946684800 102 8 Article title 2 <p>Article content 2</p>http://example.com/2 0 1 946771200 103 9 Article title 3 <p>Article content 3</p>http://example.com/3 1 0 946857600 104 9 Article title 4 <p>Article content 4</p>http://example.com/4 1 1 946944000 105 10 Article title 5 <p>Article content 5</p>http://example.com/5 0 0 947030400 1024 ");
- $act = $this->h->dispatch($this->req("api=xml"));
- $this->assertMessage($exp, $act);
+ $this->assertMessage($exp, $this->req("api=xml"));
}
public function testListFeedIcons(): void {
$iconType = (new \ReflectionClassConstant(API::class, "GENERIC_ICON_TYPE"))->getValue();
$iconData = (new \ReflectionClassConstant(API::class, "GENERIC_ICON_DATA"))->getValue();
- $act = $this->h->dispatch($this->req("api&favicons"));
- $exp = new JsonResponse(['favicons' => [['id' => 0, 'data' => $iconType.",".$iconData]]]);
- $this->assertMessage($exp, $act);
+ $this->dbMock->iconList->returns(new Result($this->v([
+ ['id' => 42, 'type' => "image/svg+xml", 'data' => " "],
+ ['id' => 44, 'type' => null, 'data' => "IMAGE DATA"],
+ ['id' => 47, 'type' => null, 'data' => null],
+ ])));
+ $exp = new JsonResponse(['favicons' => [
+ ['id' => 0, 'data' => $iconType.",".$iconData],
+ ['id' => 42, 'data' => "image/svg+xml;base64,PHN2Zy8+"],
+ ['id' => 44, 'data' => "application/octet-stream;base64,SU1BR0UgREFUQQ=="],
+ ]]);
+ $this->assertMessage($exp, $this->req("api&favicons"));
}
public function testAnswerOptionsRequest(): void {
- $act = $this->h->dispatch($this->req("api", "", "OPTIONS"));
$exp = new EmptyResponse(204, [
'Allow' => "POST",
'Accept' => "application/x-www-form-urlencoded, multipart/form-data",
]);
- $this->assertMessage($exp, $act);
+ $this->assertMessage($exp, $this->req("api", "", "OPTIONS"));
}
}
diff --git a/tests/cases/REST/Fever/TestUser.php b/tests/cases/REST/Fever/TestUser.php
index d6bab6df..3700e195 100644
--- a/tests/cases/REST/Fever/TestUser.php
+++ b/tests/cases/REST/Fever/TestUser.php
@@ -10,7 +10,7 @@ use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\Db\ExceptionInput;
-use JKingWeb\Arsse\User\Exception as UserException;
+use JKingWeb\Arsse\User\ExceptionConflict as UserException;
use JKingWeb\Arsse\Db\Transaction;
use JKingWeb\Arsse\REST\Fever\User as FeverUser;
@@ -19,39 +19,40 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
protected $u;
public function setUp(): void {
- self::clearData();
+ parent::setUp();
self::setConf();
// create a mock user manager
- Arsse::$user = \Phake::mock(User::class);
- \Phake::when(Arsse::$user)->auth->thenReturn(true);
+ $this->userMock = $this->mock(User::class);
+ $this->userMock->auth->returns(true);
// create a mock database interface
- Arsse::$db = \Phake::mock(Database::class);
- \Phake::when(Arsse::$db)->begin->thenReturn(\Phake::mock(Transaction::class));
- // instantiate the handler
- $this->u = new FeverUser();
+ $this->dbMock = $this->mock(Database::class);
+ $this->dbMock->begin->returns($this->mock(Transaction::class));
}
- public function tearDown(): void {
- self::clearData();
+ protected function prepTest(): FeverUser {
+ Arsse::$user = $this->userMock->get();
+ Arsse::$db = $this->dbMock->get();
+ // instantiate the handler
+ return new FeverUser;
}
/** @dataProvider providePasswordCreations */
public function testRegisterAUserPassword(string $user, string $password = null, $exp): void {
- \Phake::when(Arsse::$user)->generatePassword->thenReturn("RANDOM_PASSWORD");
- \Phake::when(Arsse::$db)->tokenCreate->thenReturnCallback(function($user, $class, $id = null) {
+ $this->userMock->generatePassword->returns("RANDOM_PASSWORD");
+ $this->dbMock->tokenCreate->does(function($user, $class, $id = null) {
return $id ?? "RANDOM_TOKEN";
});
- \Phake::when(Arsse::$db)->tokenCreate("john.doe@example.org", $this->anything(), $this->anything())->thenThrow(new UserException("doesNotExist"));
+ $this->dbMock->tokenCreate->with("john.doe@example.org", $this->anything(), $this->anything())->throws(new UserException("doesNotExist"));
try {
if ($exp instanceof \JKingWeb\Arsse\AbstractException) {
$this->assertException($exp);
- $this->u->register($user, $password);
+ $this->prepTest()->register($user, $password);
} else {
- $this->assertSame($exp, $this->u->register($user, $password));
+ $this->assertSame($exp, $this->prepTest()->register($user, $password));
}
} finally {
- \Phake::verify(Arsse::$db)->tokenRevoke($user, "fever.login");
- \Phake::verify(Arsse::$db)->tokenCreate($user, "fever.login", md5($user.":".($password ?? "RANDOM_PASSWORD")));
+ $this->dbMock->tokenRevoke->calledWith($user, "fever.login");
+ $this->dbMock->tokenCreate->calledWith($user, "fever.login", md5($user.":".($password ?? "RANDOM_PASSWORD")));
}
}
@@ -67,20 +68,20 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testUnregisterAUser(): void {
- \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(3);
- $this->assertTrue($this->u->unregister("jane.doe@example.com"));
- \Phake::verify(Arsse::$db)->tokenRevoke("jane.doe@example.com", "fever.login");
- \Phake::when(Arsse::$db)->tokenRevoke->thenReturn(0);
- $this->assertFalse($this->u->unregister("john.doe@example.com"));
- \Phake::verify(Arsse::$db)->tokenRevoke("john.doe@example.com", "fever.login");
+ $this->dbMock->tokenRevoke->returns(3);
+ $this->assertTrue($this->prepTest()->unregister("jane.doe@example.com"));
+ $this->dbMock->tokenRevoke->calledWith("jane.doe@example.com", "fever.login");
+ $this->dbMock->tokenRevoke->returns(0);
+ $this->assertFalse($this->prepTest()->unregister("john.doe@example.com"));
+ $this->dbMock->tokenRevoke->calledWith("john.doe@example.com", "fever.login");
}
/** @dataProvider provideUserAuthenticationRequests */
public function testAuthenticateAUserName(string $user, string $password, bool $exp): void {
- \Phake::when(Arsse::$db)->tokenLookup->thenThrow(new ExceptionInput("constraintViolation"));
- \Phake::when(Arsse::$db)->tokenLookup("fever.login", md5("jane.doe@example.com:secret"))->thenReturn(['user' => "jane.doe@example.com"]);
- \Phake::when(Arsse::$db)->tokenLookup("fever.login", md5("john.doe@example.com:superman"))->thenReturn(['user' => "john.doe@example.com"]);
- $this->assertSame($exp, $this->u->authenticate($user, $password));
+ $this->dbMock->tokenLookup->throws(new ExceptionInput("constraintViolation"));
+ $this->dbMock->tokenLookup->with("fever.login", md5("jane.doe@example.com:secret"))->returns(['user' => "jane.doe@example.com"]);
+ $this->dbMock->tokenLookup->with("fever.login", md5("john.doe@example.com:superman"))->returns(['user' => "john.doe@example.com"]);
+ $this->assertSame($exp, $this->prepTest()->authenticate($user, $password));
}
public function provideUserAuthenticationRequests(): iterable {
diff --git a/tests/cases/REST/Miniflux/PDO/TestToken.php b/tests/cases/REST/Miniflux/PDO/TestToken.php
new file mode 100644
index 00000000..1a561d08
--- /dev/null
+++ b/tests/cases/REST/Miniflux/PDO/TestToken.php
@@ -0,0 +1,13 @@
+
+ * @group optional */
+class TestToken extends \JKingWeb\Arsse\TestCase\REST\Miniflux\TestV1 {
+ use \JKingWeb\Arsse\Test\PDOTest;
+}
diff --git a/tests/cases/REST/Miniflux/PDO/TestV1.php b/tests/cases/REST/Miniflux/PDO/TestV1.php
new file mode 100644
index 00000000..977ffa4e
--- /dev/null
+++ b/tests/cases/REST/Miniflux/PDO/TestV1.php
@@ -0,0 +1,13 @@
+
+ * @group optional */
+class TestV1 extends \JKingWeb\Arsse\TestCase\REST\Miniflux\TestV1 {
+ use \JKingWeb\Arsse\Test\PDOTest;
+}
diff --git a/tests/cases/REST/Miniflux/TestErrorResponse.php b/tests/cases/REST/Miniflux/TestErrorResponse.php
new file mode 100644
index 00000000..5852b4d0
--- /dev/null
+++ b/tests/cases/REST/Miniflux/TestErrorResponse.php
@@ -0,0 +1,22 @@
+assertSame('{"error_message":"Access Unauthorized"}', (string) $act->getBody());
+ }
+
+ public function testCreateVariableResponse(): void {
+ $act = new ErrorResponse(["InvalidBodyJSON", "Doh!"], 401);
+ $this->assertSame('{"error_message":"Invalid JSON payload: Doh!"}', (string) $act->getBody());
+ }
+}
diff --git a/tests/cases/REST/Miniflux/TestStatus.php b/tests/cases/REST/Miniflux/TestStatus.php
new file mode 100644
index 00000000..bcf81d18
--- /dev/null
+++ b/tests/cases/REST/Miniflux/TestStatus.php
@@ -0,0 +1,34 @@
+dispatch($this->serverRequest($method, $url, ""));
+ $this->assertMessage($exp, $act);
+ }
+
+ public function provideRequests(): iterable {
+ return [
+ ["/version", "GET", new TextResponse(V1::VERSION)],
+ ["/version", "POST", new EmptyResponse(405, ['Allow' => "HEAD, GET"])],
+ ["/version", "OPTIONS", new EmptyResponse(204, ['Allow' => "HEAD, GET"])],
+ ["/healthcheck", "GET", new TextResponse("OK")],
+ ["/healthcheck", "POST", new EmptyResponse(405, ['Allow' => "HEAD, GET"])],
+ ["/healthcheck", "OPTIONS", new EmptyResponse(204, ['Allow' => "HEAD, GET"])],
+ ["/version/", "GET", new EmptyResponse(404)],
+ ];
+ }
+}
diff --git a/tests/cases/REST/Miniflux/TestToken.php b/tests/cases/REST/Miniflux/TestToken.php
new file mode 100644
index 00000000..484d9b24
--- /dev/null
+++ b/tests/cases/REST/Miniflux/TestToken.php
@@ -0,0 +1,71 @@
+ */
+class TestToken extends \JKingWeb\Arsse\Test\AbstractTest {
+ protected const NOW = "2020-12-09T22:35:10.023419Z";
+ protected const TOKEN = "Tk2o9YubmZIL2fm2w8Z4KlDEQJz532fNSOcTG0s2_xc=";
+
+ protected $transaction;
+
+ public function setUp(): void {
+ parent::setUp();
+ self::setConf();
+ // create a mock database interface
+ $this->dbMock = $this->mock(Database::class);
+ $this->transaction = $this->mock(Transaction::class);
+ $this->dbMock->begin->returns($this->transaction);
+ }
+
+ protected function prepTest(): Token {
+ Arsse::$db = $this->dbMock->get();
+ // instantiate the handler
+ return new Token;
+ }
+
+ protected function v($value) {
+ return $value;
+ }
+
+ public function testGenerateTokens(): void {
+ $this->dbMock->tokenCreate->returns("RANDOM TOKEN");
+ $this->assertSame("RANDOM TOKEN", $this->prepTest()->tokenGenerate("ook", "Eek"));
+ $this->dbMock->tokenCreate->calledWith("ook", "miniflux.login", "~", null, "Eek");
+ $token = $this->dbMock->tokenCreate->firstCall()->argument(2);
+ $this->assertMatchesRegularExpression("/^[A-Za-z0-9_\-]{43}=$/", $token);
+ }
+
+ public function testListTheTokensOfAUser(): void {
+ $out = [
+ ['id' => "TOKEN 1", 'data' => "Ook"],
+ ['id' => "TOKEN 2", 'data' => "Eek"],
+ ['id' => "TOKEN 3", 'data' => "Ack"],
+ ];
+ $exp = [
+ ['label' => "Ook", 'id' => "TOKEN 1"],
+ ['label' => "Eek", 'id' => "TOKEN 2"],
+ ['label' => "Ack", 'id' => "TOKEN 3"],
+ ];
+ $this->dbMock->tokenList->returns(new Result($this->v($out)));
+ $this->dbMock->userExists->returns(true);
+ $this->assertSame($exp, $this->prepTest()->tokenList("john.doe@example.com"));
+ $this->dbMock->tokenList->calledWith("john.doe@example.com", "miniflux.login");
+ }
+
+ public function testListTheTokensOfAMissingUser(): void {
+ $this->dbMock->userExists->returns(false);
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ $this->prepTest()->tokenList("john.doe@example.com");
+ }
+}
diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php
new file mode 100644
index 00000000..f1dd8d33
--- /dev/null
+++ b/tests/cases/REST/Miniflux/TestV1.php
@@ -0,0 +1,981 @@
+ */
+class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
+ protected const NOW = "2020-12-09T22:35:10.023419Z";
+ protected const TOKEN = "Tk2o9YubmZIL2fm2w8Z4KlDEQJz532fNSOcTG0s2_xc=";
+ protected const USERS = [
+ ['id' => 1, 'username' => "john.doe@example.com", 'last_login_at' => self::NOW, 'google_id' => "", 'openid_connect_id' => "", 'is_admin' => true, 'theme' => "custom", 'language' => "fr_CA", 'timezone' => "Asia/Gaza", 'entry_sorting_direction' => "asc", 'entries_per_page' => 200, 'keyboard_shortcuts' => false, 'show_reading_time' => false, 'entry_swipe' => false, 'stylesheet' => "p {}"],
+ ['id' => 2, 'username' => "jane.doe@example.com", 'last_login_at' => self::NOW, 'google_id' => "", 'openid_connect_id' => "", 'is_admin' => false, 'theme' => "light_serif", 'language' => "en_US", 'timezone' => "UTC", 'entry_sorting_direction' => "desc", 'entries_per_page' => 100, 'keyboard_shortcuts' => true, 'show_reading_time' => true, 'entry_swipe' => true, 'stylesheet' => ""],
+ ];
+ protected const FEEDS = [
+ ['id' => 1, 'feed' => 12, 'url' => "http://example.com/ook", 'title' => "Ook", 'source' => "http://example.com/", 'icon_id' => 47, 'icon_url' => "http://example.com/icon", 'folder' => 2112, 'top_folder' => 5, 'folder_name' => "Cat Eek", 'top_folder_name' => "Cat Ook", 'pinned' => 0, 'err_count' => 1, 'err_msg' => "Oopsie", 'order_type' => 0, 'keep_rule' => "this|that", 'block_rule' => "both", 'added' => "2020-12-21 21:12:00", 'updated' => "2021-01-05 13:51:32", 'edited' => "2021-01-01 00:00:00", 'modified' => "2020-11-30 04:08:52", 'next_fetch' => "2021-01-20 00:00:00", 'etag' => "OOKEEK", 'scrape' => 0, 'unread' => 42],
+ ['id' => 55, 'feed' => 12, 'url' => "http://j%20k:super%20secret@example.com/eek", 'title' => "Eek", 'source' => "http://example.com/", 'icon_id' => null, 'icon_url' => null, 'folder' => null, 'top_folder' => null, 'folder_name' => null, 'top_folder_name' => null, 'pinned' => 0, 'err_count' => 0, 'err_msg' => null, 'order_type' => 0, 'keep_rule' => null, 'block_rule' => null, 'added' => "2020-12-21 21:12:00", 'updated' => "2021-01-05 13:51:32", 'edited' => null, 'modified' => "2020-11-30 04:08:52", 'next_fetch' => null, 'etag' => null, 'scrape' => 1, 'unread' => 0],
+ ];
+ protected const FEEDS_OUT = [
+ ['id' => 1, 'user_id' => 42, 'feed_url' => "http://example.com/ook", 'site_url' => "http://example.com/", 'title' => "Ook", 'checked_at' => "2021-01-05T15:51:32.000000+02:00", 'next_check_at' => "2021-01-20T02:00:00.000000+02:00", 'etag_header' => "OOKEEK", 'last_modified_header' => "Fri, 01 Jan 2021 00:00:00 GMT", 'parsing_error_message' => "Oopsie", 'parsing_error_count' => 1, 'scraper_rules' => "", 'rewrite_rules' => "", 'crawler' => false, 'blocklist_rules' => "both", 'keeplist_rules' => "this|that", 'user_agent' => "", 'username' => "", 'password' => "", 'disabled' => false, 'ignore_http_cache' => false, 'fetch_via_proxy' => false, 'category' => ['id' => 6, 'title' => "Cat Ook", 'user_id' => 42], 'icon' => ['feed_id' => 1,'icon_id' => 47]],
+ ['id' => 55, 'user_id' => 42, 'feed_url' => "http://example.com/eek", 'site_url' => "http://example.com/", 'title' => "Eek", 'checked_at' => "2021-01-05T15:51:32.000000+02:00", 'next_check_at' => "0001-01-01T00:00:00Z", 'etag_header' => "", 'last_modified_header' => "", 'parsing_error_message' => "", 'parsing_error_count' => 0, 'scraper_rules' => "", 'rewrite_rules' => "", 'crawler' => true, 'blocklist_rules' => "", 'keeplist_rules' => "", 'user_agent' => "", 'username' => "j k", 'password' => "super secret", 'disabled' => false, 'ignore_http_cache' => false, 'fetch_via_proxy' => false, 'category' => ['id' => 1,'title' => "All", 'user_id' => 42], 'icon' => null],
+ ];
+ protected const ENTRIES = [
+ ['id' => 42, 'url' => "http://example.com/42", 'title' => "Title 42", 'subscription' => 55, 'author' => "Thomas Costain", 'fingerprint' => "FINGERPRINT", 'published_date' => "2021-01-22 02:21:12", 'modified_date' => "2021-01-22 13:44:47", 'starred' => 0, 'unread' => 0, 'hidden' => 0, 'content' => "Content 42", 'media_url' => null, 'media_type' => null],
+ ['id' => 44, 'url' => "http://example.com/44", 'title' => "Title 44", 'subscription' => 55, 'author' => null, 'fingerprint' => "FINGERPRINT", 'published_date' => "2021-01-22 02:21:12", 'modified_date' => "2021-01-22 13:44:47", 'starred' => 1, 'unread' => 1, 'hidden' => 0, 'content' => "Content 44", 'media_url' => "http://example.com/44/enclosure", 'media_type' => null],
+ ['id' => 47, 'url' => "http://example.com/47", 'title' => "Title 47", 'subscription' => 55, 'author' => null, 'fingerprint' => "FINGERPRINT", 'published_date' => "2021-01-22 02:21:12", 'modified_date' => "2021-01-22 13:44:47", 'starred' => 0, 'unread' => 1, 'hidden' => 1, 'content' => "Content 47", 'media_url' => "http://example.com/47/enclosure", 'media_type' => ""],
+ ['id' => 2112, 'url' => "http://example.com/2112", 'title' => "Title 2112", 'subscription' => 55, 'author' => null, 'fingerprint' => "FINGERPRINT", 'published_date' => "2021-01-22 02:21:12", 'modified_date' => "2021-01-22 13:44:47", 'starred' => 0, 'unread' => 0, 'hidden' => 1, 'content' => "Content 2112", 'media_url' => "http://example.com/2112/enclosure", 'media_type' => "image/png"],
+ ];
+ protected const ENTRIES_OUT = [
+ ['id' => 42, 'user_id' => 42, 'feed_id' => 55, 'status' => "read", 'hash' => "FINGERPRINT", 'title' => "Title 42", 'url' => "http://example.com/42", 'comments_url' => "", 'published_at' => "2021-01-22T04:21:12+02:00", 'created_at' => "2021-01-22T15:44:47.000000+02:00", 'content' => "Content 42", 'author' => "Thomas Costain", 'share_code' => "", 'starred' => false, 'reading_time' => 0, 'enclosures' => null, 'feed' => self::FEEDS_OUT[1]],
+ ['id' => 44, 'user_id' => 42, 'feed_id' => 55, 'status' => "unread", 'hash' => "FINGERPRINT", 'title' => "Title 44", 'url' => "http://example.com/44", 'comments_url' => "", 'published_at' => "2021-01-22T04:21:12+02:00", 'created_at' => "2021-01-22T15:44:47.000000+02:00", 'content' => "Content 44", 'author' => "", 'share_code' => "", 'starred' => true, 'reading_time' => 0, 'enclosures' => [['id' => 44, 'user_id' => 42, 'entry_id' => 44, 'url' => "http://example.com/44/enclosure", 'mime_type' => "application/octet-stream", 'size' => 0]], 'feed' => self::FEEDS_OUT[1]],
+ ['id' => 47, 'user_id' => 42, 'feed_id' => 55, 'status' => "removed", 'hash' => "FINGERPRINT", 'title' => "Title 47", 'url' => "http://example.com/47", 'comments_url' => "", 'published_at' => "2021-01-22T04:21:12+02:00", 'created_at' => "2021-01-22T15:44:47.000000+02:00", 'content' => "Content 47", 'author' => "", 'share_code' => "", 'starred' => false, 'reading_time' => 0, 'enclosures' => [['id' => 47, 'user_id' => 42, 'entry_id' => 47, 'url' => "http://example.com/47/enclosure", 'mime_type' => "application/octet-stream", 'size' => 0]], 'feed' => self::FEEDS_OUT[1]],
+ ['id' => 2112, 'user_id' => 42, 'feed_id' => 55, 'status' => "removed", 'hash' => "FINGERPRINT", 'title' => "Title 2112", 'url' => "http://example.com/2112", 'comments_url' => "", 'published_at' => "2021-01-22T04:21:12+02:00", 'created_at' => "2021-01-22T15:44:47.000000+02:00", 'content' => "Content 2112", 'author' => "", 'share_code' => "", 'starred' => false, 'reading_time' => 0, 'enclosures' => [['id' => 2112, 'user_id' => 42, 'entry_id' => 2112, 'url' => "http://example.com/2112/enclosure", 'mime_type' => "image/png", 'size' => 0]], 'feed' => self::FEEDS_OUT[1]],
+ ];
+
+ protected $h;
+ protected $transaction;
+
+ protected function req(string $method, string $target, $data = "", array $headers = [], ?string $user = "john.doe@example.com", bool $body = true): ResponseInterface {
+ Arsse::$obj = $this->objMock->get();
+ Arsse::$db = $this->dbMock->get();
+ if ($this->h instanceof InstanceHandle) {
+ $this->h = $this->h->get();
+ }
+ $prefix = "/v1";
+ $url = $prefix.$target;
+ if ($body) {
+ $params = [];
+ } else {
+ $params = $data;
+ $data = [];
+ }
+ $req = $this->serverRequest($method, $url, $prefix, $headers, [], $data, "application/json", $params, $user);
+ return $this->h->dispatch($req);
+ }
+
+ public function setUp(): void {
+ parent::setUp();
+ self::setConf();
+ // create mock timestamps
+ $this->objMock->get->with(\DateTimeImmutable::class)->returns(new \DateTimeImmutable(self::NOW));
+ // create a mock database interface
+ $this->transaction = $this->mock(Transaction::class);
+ $this->dbMock = $this->mock(Database::class);
+ $this->dbMock->begin->returns($this->transaction->get());
+ // create a mock user manager; we use a PHPUnitmock because Phake for reasons unknown is unable to mock the User class correctly, sometimes
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("propertiesGet")->willReturn(['num' => 42, 'admin' => false, 'root_folder_name' => null, 'tz' => "Asia/Gaza"]);
+ Arsse::$user->method("begin")->willReturn($this->transaction->get());
+ //initialize a handler
+ $this->h = new V1();
+ }
+
+ protected function v($value) {
+ return $value;
+ }
+
+ /** @dataProvider provideAuthResponses */
+ public function testAuthenticateAUser($token, bool $auth, bool $success): void {
+ $exp = $success ? new EmptyResponse(404) : new ErrorResponse("401", 401);
+ $user = "john.doe@example.com";
+ if ($token !== null) {
+ $headers = ['X-Auth-Token' => $token];
+ } else {
+ $headers = [];
+ }
+ Arsse::$user->id = null;
+ $this->dbMock->tokenLookup->throws(new ExceptionInput("subjectMissing"));
+ $this->dbMock->tokenLookup->with("miniflux.login", self::TOKEN)->returns(['user' => $user]);
+ $this->assertMessage($exp, $this->req("GET", "/", "", $headers, $auth ? "john.doe@example.com" : null));
+ $this->assertSame($success ? $user : null, Arsse::$user->id);
+ }
+
+ public function provideAuthResponses(): iterable {
+ return [
+ [null, false, false],
+ [null, true, true],
+ [self::TOKEN, false, true],
+ [[self::TOKEN, "BOGUS"], false, true],
+ ["", true, true],
+ [["", "BOGUS"], true, true],
+ ["NOT A TOKEN", false, false],
+ ["NOT A TOKEN", true, false],
+ [["BOGUS", self::TOKEN], false, false],
+ [["", self::TOKEN], false, false],
+ ];
+ }
+
+ /** @dataProvider provideInvalidPaths */
+ public function testRespondToInvalidPaths($path, $method, $code, $allow = null): void {
+ $exp = new EmptyResponse($code, $allow ? ['Allow' => $allow] : []);
+ $this->assertMessage($exp, $this->req($method, $path));
+ }
+
+ public function provideInvalidPaths(): array {
+ return [
+ ["/", "GET", 404],
+ ["/", "OPTIONS", 404],
+ ["/me", "POST", 405, "GET"],
+ ["/me/", "GET", 404],
+ ];
+ }
+
+ /** @dataProvider provideOptionsRequests */
+ public function testRespondToOptionsRequests(string $url, string $allow, string $accept): void {
+ $exp = new EmptyResponse(204, [
+ 'Allow' => $allow,
+ 'Accept' => $accept,
+ ]);
+ $this->assertMessage($exp, $this->req("OPTIONS", $url));
+ }
+
+ public function provideOptionsRequests(): array {
+ return [
+ ["/feeds", "HEAD, GET, POST", "application/json"],
+ ["/feeds/2112", "HEAD, GET, PUT, DELETE", "application/json"],
+ ["/me", "HEAD, GET", "application/json"],
+ ["/users/someone", "HEAD, GET", "application/json"],
+ ["/import", "POST", "application/xml, text/xml, text/x-opml"],
+ ];
+ }
+
+ public function testRejectBadlyTypedData(): void {
+ $exp = new ErrorResponse(["InvalidInputType", 'field' => "url", 'expected' => "string", 'actual' => "integer"], 422);
+ $this->assertMessage($exp, $this->req("POST", "/discover", ['url' => 2112]));
+ }
+
+ /** @dataProvider provideDiscoveries */
+ public function testDiscoverFeeds($in, ResponseInterface $exp): void {
+ $this->assertMessage($exp, $this->req("POST", "/discover", ['url' => $in]));
+ }
+
+ public function provideDiscoveries(): iterable {
+ self::clearData();
+ $discovered = [
+ ['title' => "Feed", 'type' => "rss", 'url' => "http://localhost:8000/Feed/Discovery/Feed"],
+ ['title' => "Feed", 'type' => "rss", 'url' => "http://localhost:8000/Feed/Discovery/Missing"],
+ ];
+ return [
+ ["http://localhost:8000/Feed/Discovery/Valid", new Response($discovered)],
+ ["http://localhost:8000/Feed/Discovery/Invalid", new Response([])],
+ ["http://localhost:8000/Feed/Discovery/Missing", new ErrorResponse("Fetch404", 502)],
+ [1, new ErrorResponse(["InvalidInputType", 'field' => "url", 'expected' => "string", 'actual' => "integer"], 422)],
+ ["Not a URL", new ErrorResponse(["InvalidInputValue", 'field' => "url"], 422)],
+ [null, new ErrorResponse(["MissingInputValue", 'field' => "url"], 422)],
+ ];
+ }
+
+ /** @dataProvider provideUserQueries */
+ public function testQueryUsers(bool $admin, string $route, ResponseInterface $exp): void {
+ $u = [
+ ['num' => 1, 'admin' => true, 'theme' => "custom", 'lang' => "fr_CA", 'tz' => "Asia/Gaza", 'sort_asc' => true, 'page_size' => 200, 'shortcuts' => false, 'reading_time' => false, 'swipe' => false, 'stylesheet' => "p {}"],
+ ['num' => 2, 'admin' => false, 'theme' => null, 'lang' => null, 'tz' => null, 'sort_asc' => null, 'page_size' => null, 'shortcuts' => null, 'reading_time' => null, 'swipe' => null, 'stylesheet' => null],
+ new ExceptionConflict("doesNotExist"),
+ ];
+ $user = $admin ? "john.doe@example.com" : "jane.doe@example.com";
+ // FIXME: Phake is somehow unable to mock the User class correctly, so we use PHPUnit's mocks instead
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("list")->willReturn(["john.doe@example.com", "jane.doe@example.com", "admin@example.com"]);
+ Arsse::$user->method("propertiesGet")->willReturnCallback(function(string $user, bool $includeLerge = true) use ($u) {
+ if ($user === "john.doe@example.com") {
+ return $u[0];
+ } elseif ($user === "jane.doe@example.com") {
+ return $u[1];
+ } else {
+ throw $u[2];
+ }
+ });
+ Arsse::$user->method("lookup")->willReturnCallback(function(int $num) use ($u) {
+ if ($num === 1) {
+ return "john.doe@example.com";
+ } elseif ($num === 2) {
+ return "jane.doe@example.com";
+ } else {
+ throw $u[2];
+ }
+ });
+ $this->assertMessage($exp, $this->req("GET", $route, "", [], $user));
+ }
+
+ public function provideUserQueries(): iterable {
+ self::clearData();
+ return [
+ [true, "/users", new Response(self::USERS)],
+ [true, "/me", new Response(self::USERS[0])],
+ [true, "/users/john.doe@example.com", new Response(self::USERS[0])],
+ [true, "/users/1", new Response(self::USERS[0])],
+ [true, "/users/jane.doe@example.com", new Response(self::USERS[1])],
+ [true, "/users/2", new Response(self::USERS[1])],
+ [true, "/users/jack.doe@example.com", new ErrorResponse("404", 404)],
+ [true, "/users/47", new ErrorResponse("404", 404)],
+ [false, "/users", new ErrorResponse("403", 403)],
+ [false, "/me", new Response(self::USERS[1])],
+ [false, "/users/john.doe@example.com", new ErrorResponse("403", 403)],
+ [false, "/users/1", new ErrorResponse("403", 403)],
+ [false, "/users/jane.doe@example.com", new ErrorResponse("403", 403)],
+ [false, "/users/2", new ErrorResponse("403", 403)],
+ [false, "/users/jack.doe@example.com", new ErrorResponse("403", 403)],
+ [false, "/users/47", new ErrorResponse("403", 403)],
+ ];
+ }
+
+ /** @dataProvider provideUserModifications */
+ public function testModifyAUser(bool $admin, string $url, array $body, $in1, $out1, $in2, $out2, $in3, $out3, ResponseInterface $exp): void {
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("begin")->willReturn($this->transaction->get());
+ Arsse::$user->method("propertiesGet")->willReturnCallback(function(string $u, bool $includeLarge) use ($admin) {
+ if ($u === "john.doe@example.com" || $u === "ook") {
+ return ['num' => 2, 'admin' => $admin];
+ } else {
+ return ['num' => 1, 'admin' => true];
+ }
+ });
+ Arsse::$user->method("lookup")->willReturnCallback(function(int $u) {
+ if ($u === 1) {
+ return "jane.doe@example.com";
+ } elseif ($u === 2) {
+ return "john.doe@example.com";
+ } else {
+ throw new ExceptionConflict("doesNotExist");
+ }
+ });
+ if ($out1 instanceof \Exception) {
+ Arsse::$user->method("rename")->willThrowException($out1);
+ } else {
+ Arsse::$user->method("rename")->willReturn($out1 ?? false);
+ }
+ if ($out2 instanceof \Exception) {
+ Arsse::$user->method("passwordSet")->willThrowException($out2);
+ } else {
+ Arsse::$user->method("passwordSet")->willReturn($out2 ?? "");
+ }
+ if ($out3 instanceof \Exception) {
+ Arsse::$user->method("propertiesSet")->willThrowException($out3);
+ } else {
+ Arsse::$user->method("propertiesSet")->willReturn($out3 ?? []);
+ }
+ $user = $url === "/users/1" ? "jane.doe@example.com" : "john.doe@example.com";
+ if ($in1 === null) {
+ Arsse::$user->expects($this->exactly(0))->method("rename");
+ } else {
+ Arsse::$user->expects($this->exactly(1))->method("rename")->with($user, $in1);
+ $user = $in1;
+ }
+ if ($in2 === null) {
+ Arsse::$user->expects($this->exactly(0))->method("passwordSet");
+ } else {
+ Arsse::$user->expects($this->exactly(1))->method("passwordSet")->with($user, $in2);
+ }
+ if ($in3 === null) {
+ Arsse::$user->expects($this->exactly(0))->method("propertiesSet");
+ } else {
+ Arsse::$user->expects($this->exactly(1))->method("propertiesSet")->with($user, $in3);
+ }
+ $this->assertMessage($exp, $this->req("PUT", $url, $body));
+ }
+
+ public function provideUserModifications(): iterable {
+ $out1 = ['num' => 2, 'admin' => false];
+ $out2 = ['num' => 1, 'admin' => false];
+ $resp1 = array_merge(self::USERS[1], ['username' => "john.doe@example.com"]);
+ $resp2 = array_merge(self::USERS[1], ['id' => 1, 'is_admin' => true]);
+ return [
+ [false, "/users/1", ['is_admin' => 0], null, null, null, null, null, null, new ErrorResponse(["InvalidInputType", 'field' => "is_admin", 'expected' => "boolean", 'actual' => "integer"], 422)],
+ [false, "/users/1", ['entry_sorting_direction' => "bad"], null, null, null, null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "entry_sorting_direction"], 422)],
+ [false, "/users/1", ['theme' => "stark"], null, null, null, null, null, null, new ErrorResponse("403", 403)],
+ [false, "/users/2", ['is_admin' => true], null, null, null, null, null, null, new ErrorResponse("InvalidElevation", 403)],
+ [false, "/users/2", ['language' => "fr_CA"], null, null, null, null, ['lang' => "fr_CA"], $out1, new Response($resp1, 201)],
+ [false, "/users/2", ['entry_sorting_direction' => "asc"], null, null, null, null, ['sort_asc' => true], $out1, new Response($resp1, 201)],
+ [false, "/users/2", ['entry_sorting_direction' => "desc"], null, null, null, null, ['sort_asc' => false], $out1, new Response($resp1, 201)],
+ [false, "/users/2", ['entries_per_page' => -1], null, null, null, null, ['page_size' => -1], new UserExceptionInput("invalidNonZeroInteger"), new ErrorResponse(["InvalidInputValue", 'field' => "entries_per_page"], 422)],
+ [false, "/users/2", ['timezone' => "Ook"], null, null, null, null, ['tz' => "Ook"], new UserExceptionInput("invalidTimezone"), new ErrorResponse(["InvalidInputValue", 'field' => "timezone"], 422)],
+ [false, "/users/2", ['username' => "j:k"], "j:k", new UserExceptionInput("invalidUsername"), null, null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "username"], 422)],
+ [false, "/users/2", ['username' => "ook"], "ook", new ExceptionConflict("alreadyExists"), null, null, null, null, new ErrorResponse(["DuplicateUser", 'user' => "ook"], 409)],
+ [false, "/users/2", ['password' => "ook"], null, null, "ook", "ook", null, null, new Response(array_merge($resp1, ['password' => "ook"]), 201)],
+ [false, "/users/2", ['username' => "ook", 'password' => "ook"], "ook", true, "ook", "ook", null, null, new Response(array_merge($resp1, ['username' => "ook", 'password' => "ook"]), 201)],
+ [true, "/users/1", ['theme' => "stark"], null, null, null, null, ['theme' => "stark"], $out2, new Response($resp2, 201)],
+ [true, "/users/3", ['theme' => "stark"], null, null, null, null, null, null, new ErrorResponse("404", 404)],
+ ];
+ }
+
+ /** @dataProvider provideUserAdditions */
+ public function testAddAUser(array $body, $in1, $out1, $in2, $out2, ResponseInterface $exp): void {
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("begin")->willReturn($this->transaction->get());
+ Arsse::$user->method("propertiesGet")->willReturnCallback(function(string $u, bool $includeLarge) {
+ if ($u === "john.doe@example.com") {
+ return ['num' => 1, 'admin' => true];
+ } else {
+ return ['num' => 2, 'admin' => false];
+ }
+ });
+ if ($out1 instanceof \Exception) {
+ Arsse::$user->method("add")->willThrowException($out1);
+ } else {
+ Arsse::$user->method("add")->willReturn($in1[1] ?? "");
+ }
+ if ($out2 instanceof \Exception) {
+ Arsse::$user->method("propertiesSet")->willThrowException($out2);
+ } else {
+ Arsse::$user->method("propertiesSet")->willReturn($out2 ?? []);
+ }
+ if ($in1 === null) {
+ Arsse::$user->expects($this->exactly(0))->method("add");
+ } else {
+ Arsse::$user->expects($this->exactly(1))->method("add")->with(...($in1 ?? []));
+ }
+ if ($in2 === null) {
+ Arsse::$user->expects($this->exactly(0))->method("propertiesSet");
+ } else {
+ Arsse::$user->expects($this->exactly(1))->method("propertiesSet")->with($body['username'], $in2);
+ }
+ $this->assertMessage($exp, $this->req("POST", "/users", $body));
+ }
+
+ public function provideUserAdditions(): iterable {
+ $resp1 = array_merge(self::USERS[1], ['username' => "ook", 'password' => "eek"]);
+ return [
+ [[], null, null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "username"], 422)],
+ [['username' => "ook"], null, null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "password"], 422)],
+ [['username' => "ook", 'password' => "eek"], ["ook", "eek"], new ExceptionConflict("alreadyExists"), null, null, new ErrorResponse(["DuplicateUser", 'user' => "ook"], 409)],
+ [['username' => "j:k", 'password' => "eek"], ["j:k", "eek"], new UserExceptionInput("invalidUsername"), null, null, new ErrorResponse(["InvalidInputValue", 'field' => "username"], 422)],
+ [['username' => "ook", 'password' => "eek", 'timezone' => "ook"], ["ook", "eek"], "eek", ['tz' => "ook"], new UserExceptionInput("invalidTimezone"), new ErrorResponse(["InvalidInputValue", 'field' => "timezone"], 422)],
+ [['username' => "ook", 'password' => "eek", 'entries_per_page' => -1], ["ook", "eek"], "eek", ['page_size' => -1], new UserExceptionInput("invalidNonZeroInteger"), new ErrorResponse(["InvalidInputValue", 'field' => "entries_per_page"], 422)],
+ [['username' => "ook", 'password' => "eek", 'theme' => "default"], ["ook", "eek"], "eek", ['theme' => "default"], ['theme' => "default"], new Response($resp1, 201)],
+ ];
+ }
+
+ public function testAddAUserWithoutAuthority(): void {
+ $this->assertMessage(new ErrorResponse("403", 403), $this->req("POST", "/users", []));
+ }
+
+ public function testDeleteAUser(): void {
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("propertiesGet")->willReturn(['admin' => true]);
+ Arsse::$user->method("lookup")->willReturn("john.doe@example.com");
+ Arsse::$user->method("remove")->willReturn(true);
+ Arsse::$user->expects($this->exactly(1))->method("lookup")->with(2112);
+ Arsse::$user->expects($this->exactly(1))->method("remove")->with("john.doe@example.com");
+ $this->assertMessage(new EmptyResponse(204), $this->req("DELETE", "/users/2112"));
+ }
+
+ public function testDeleteAMissingUser(): void {
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("propertiesGet")->willReturn(['admin' => true]);
+ Arsse::$user->method("lookup")->willThrowException(new ExceptionConflict("doesNotExist"));
+ Arsse::$user->method("remove")->willReturn(true);
+ Arsse::$user->expects($this->exactly(1))->method("lookup")->with(2112);
+ Arsse::$user->expects($this->exactly(0))->method("remove");
+ $this->assertMessage(new ErrorResponse("404", 404), $this->req("DELETE", "/users/2112"));
+ }
+
+ public function testDeleteAUserWithoutAuthority(): void {
+ Arsse::$user->expects($this->exactly(0))->method("lookup");
+ Arsse::$user->expects($this->exactly(0))->method("remove");
+ $this->assertMessage(new ErrorResponse("403", 403), $this->req("DELETE", "/users/2112"));
+ }
+
+ public function testListCategories(): void {
+ $this->dbMock->folderList->returns(new Result($this->v([
+ ['id' => 1, 'name' => "Science"],
+ ['id' => 20, 'name' => "Technology"],
+ ])));
+ $exp = new Response([
+ ['id' => 1, 'title' => "All", 'user_id' => 42],
+ ['id' => 2, 'title' => "Science", 'user_id' => 42],
+ ['id' => 21, 'title' => "Technology", 'user_id' => 42],
+ ]);
+ $this->assertMessage($exp, $this->req("GET", "/categories"));
+ $this->dbMock->folderList->calledWith("john.doe@example.com", null, false);
+ // run test again with a renamed root folder
+ Arsse::$user = $this->createMock(User::class);
+ Arsse::$user->method("propertiesGet")->willReturn(['num' => 47, 'admin' => false, 'root_folder_name' => "Uncategorized"]);
+ $exp = new Response([
+ ['id' => 1, 'title' => "Uncategorized", 'user_id' => 47],
+ ['id' => 2, 'title' => "Science", 'user_id' => 47],
+ ['id' => 21, 'title' => "Technology", 'user_id' => 47],
+ ]);
+ $this->assertMessage($exp, $this->req("GET", "/categories"));
+ }
+
+ /** @dataProvider provideCategoryAdditions */
+ public function testAddACategory($title, ResponseInterface $exp): void {
+ if (!strlen((string) $title)) {
+ $this->dbMock->folderAdd->throws(new ExceptionInput("missing"));
+ } elseif (!strlen(trim((string) $title))) {
+ $this->dbMock->folderAdd->throws(new ExceptionInput("whitespace"));
+ } elseif ($title === "Duplicate") {
+ $this->dbMock->folderAdd->throws(new ExceptionInput("constraintViolation"));
+ } else {
+ $this->dbMock->folderAdd->returns(2111);
+ }
+ $this->assertMessage($exp, $this->req("POST", "/categories", ['title' => $title]));
+ }
+
+ public function provideCategoryAdditions(): iterable {
+ return [
+ ["New", new Response(['id' => 2112, 'title' => "New", 'user_id' => 42], 201)],
+ ["Duplicate", new ErrorResponse(["DuplicateCategory", 'title' => "Duplicate"], 409)],
+ ["", new ErrorResponse(["InvalidCategory", 'title' => ""], 422)],
+ [" ", new ErrorResponse(["InvalidCategory", 'title' => " "], 422)],
+ [null, new ErrorResponse(["MissingInputValue", 'field' => "title"], 422)],
+ [false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 422)],
+ ];
+ }
+
+ /** @dataProvider provideCategoryUpdates */
+ public function testRenameACategory(int $id, $title, $out, ResponseInterface $exp): void {
+ Arsse::$user->method("propertiesSet")->willReturn(['root_folder_name' => $title]);
+ if (is_string($out)) {
+ $this->dbMock->folderPropertiesSet->throws(new ExceptionInput($out));
+ } else {
+ $this->dbMock->folderPropertiesSet->returns($out);
+ }
+ $times = (int) ($id === 1 && is_string($title) && strlen(trim($title)));
+ Arsse::$user->expects($this->exactly($times))->method("propertiesSet")->with("john.doe@example.com", ['root_folder_name' => $title]);
+ $this->assertMessage($exp, $this->req("PUT", "/categories/$id", ['title' => $title]));
+ $times = (int) ($id !== 1 && is_string($title));
+ $this->dbMock->folderPropertiesSet->times($times)->calledWith("john.doe@example.com", $id - 1, ['name' => $title]);
+ }
+
+ public function provideCategoryUpdates(): iterable {
+ return [
+ [3, "New", "subjectMissing", new ErrorResponse("404", 404)],
+ [2, "New", true, new Response(['id' => 2, 'title' => "New", 'user_id' => 42], 201)],
+ [2, "Duplicate", "constraintViolation", new ErrorResponse(["DuplicateCategory", 'title' => "Duplicate"], 409)],
+ [2, "", "missing", new ErrorResponse(["InvalidCategory", 'title' => ""], 422)],
+ [2, " ", "whitespace", new ErrorResponse(["InvalidCategory", 'title' => " "], 422)],
+ [2, null, "missing", new ErrorResponse(["MissingInputValue", 'field' => "title"], 422)],
+ [2, false, "subjectMissing", new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 422)],
+ [1, "New", true, new Response(['id' => 1, 'title' => "New", 'user_id' => 42], 201)],
+ [1, "Duplicate", "constraintViolation", new Response(['id' => 1, 'title' => "Duplicate", 'user_id' => 42], 201)], // This is allowed because the name of the root folder is only a duplicate in circumstances where it is used
+ [1, "", "missing", new ErrorResponse(["InvalidCategory", 'title' => ""], 422)],
+ [1, " ", "whitespace", new ErrorResponse(["InvalidCategory", 'title' => " "], 422)],
+ [1, null, "missing", new ErrorResponse(["MissingInputValue", 'field' => "title"], 422)],
+ [1, false, false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 422)],
+ ];
+ }
+
+ public function testDeleteARealCategory(): void {
+ $this->dbMock->folderRemove->returns(true)->throws(new ExceptionInput("subjectMissing"));
+ $this->assertMessage(new EmptyResponse(204), $this->req("DELETE", "/categories/2112"));
+ $this->dbMock->folderRemove->calledWith("john.doe@example.com", 2111);
+ $this->assertMessage(new ErrorResponse("404", 404), $this->req("DELETE", "/categories/47"));
+ $this->dbMock->folderRemove->calledWith("john.doe@example.com", 46);
+ }
+
+ public function testDeleteTheSpecialCategory(): void {
+ $this->dbMock->subscriptionList->returns(new Result($this->v([
+ ['id' => 1],
+ ['id' => 47],
+ ['id' => 2112],
+ ])));
+ $this->dbMock->subscriptionRemove->returns(true);
+ $this->assertMessage(new EmptyResponse(204), $this->req("DELETE", "/categories/1"));
+ Phony::inOrder(
+ $this->dbMock->begin->calledWith(),
+ $this->dbMock->subscriptionList->calledWith("john.doe@example.com", null, false),
+ $this->dbMock->subscriptionRemove->calledWith("john.doe@example.com", 1),
+ $this->dbMock->subscriptionRemove->calledWith("john.doe@example.com", 47),
+ $this->dbMock->subscriptionRemove->calledWith("john.doe@example.com", 2112),
+ $this->transaction->commit->called()
+ );
+ }
+
+ public function testListFeeds(): void {
+ $this->dbMock->subscriptionList->returns(new Result($this->v(self::FEEDS)));
+ $exp = new Response(self::FEEDS_OUT);
+ $this->assertMessage($exp, $this->req("GET", "/feeds"));
+ }
+
+ public function testListFeedsOfACategory(): void {
+ $this->dbMock->subscriptionList->returns(new Result($this->v(self::FEEDS)));
+ $exp = new Response(self::FEEDS_OUT);
+ $this->assertMessage($exp, $this->req("GET", "/categories/2112/feeds"));
+ $this->dbMock->subscriptionList->calledWith(Arsse::$user->id, 2111, true);
+ }
+
+ public function testListFeedsOfTheRootCategory(): void {
+ $this->dbMock->subscriptionList->returns(new Result($this->v(self::FEEDS)));
+ $exp = new Response(self::FEEDS_OUT);
+ $this->assertMessage($exp, $this->req("GET", "/categories/1/feeds"));
+ $this->dbMock->subscriptionList->calledWith(Arsse::$user->id, 0, false);
+ }
+
+ public function testListFeedsOfAMissingCategory(): void {
+ $this->dbMock->subscriptionList->throws(new ExceptionInput("idMissing"));
+ $exp = new ErrorResponse("404", 404);
+ $this->assertMessage($exp, $this->req("GET", "/categories/2112/feeds"));
+ $this->dbMock->subscriptionList->calledWith(Arsse::$user->id, 2111, true);
+ }
+
+ public function testGetAFeed(): void {
+ $this->dbMock->subscriptionPropertiesGet->returns($this->v(self::FEEDS[0]))->returns($this->v(self::FEEDS[1]));
+ $this->assertMessage(new Response(self::FEEDS_OUT[0]), $this->req("GET", "/feeds/1"));
+ $this->dbMock->subscriptionPropertiesGet->calledWith(Arsse::$user->id, 1);
+ $this->assertMessage(new Response(self::FEEDS_OUT[1]), $this->req("GET", "/feeds/55"));
+ $this->dbMock->subscriptionPropertiesGet->calledWith(Arsse::$user->id, 55);
+ }
+
+ public function testGetAMissingFeed(): void {
+ $this->dbMock->subscriptionPropertiesGet->throws(new ExceptionInput("subjectMissing"));
+ $this->assertMessage(new ErrorResponse("404", 404), $this->req("GET", "/feeds/1"));
+ $this->dbMock->subscriptionPropertiesGet->calledWith(Arsse::$user->id, 1);
+ }
+
+ /** @dataProvider provideFeedCreations */
+ public function testCreateAFeed(array $in, $out1, $out2, $out3, $out4, ResponseInterface $exp): void {
+ if ($out1 instanceof \Exception) {
+ $this->dbMock->feedAdd->throws($out1);
+ } else {
+ $this->dbMock->feedAdd->returns($out1);
+ }
+ if ($out2 instanceof \Exception) {
+ $this->dbMock->subscriptionAdd->throws($out2);
+ } else {
+ $this->dbMock->subscriptionAdd->returns($out2);
+ }
+ if ($out3 instanceof \Exception) {
+ $this->dbMock->subscriptionPropertiesSet->throws($out3);
+ } elseif ($out4 instanceof \Exception) {
+ $this->dbMock->subscriptionPropertiesSet->returns($out3)->throws($out4);
+ } else {
+ $this->dbMock->subscriptionPropertiesSet->returns($out3)->returns($out4);
+ }
+ $this->assertMessage($exp, $this->req("POST", "/feeds", $in));
+ $in1 = $out1 !== null;
+ $in2 = $out2 !== null;
+ $in3 = $out3 !== null;
+ $in4 = $out4 !== null;
+ if ($in1) {
+ $this->dbMock->feedAdd->calledWith($in['feed_url'], $in['username'] ?? "", $in['password'] ?? "", false, $in['crawler'] ?? false);
+ } else {
+ $this->dbMock->feedAdd->never()->called();
+ }
+ if ($in2) {
+ $this->dbMock->begin->calledWith();
+ $this->dbMock->subscriptionAdd->calledWith("john.doe@example.com", $in['feed_url'], $in['username'] ?? "", $in['password'] ?? "", false, $in['crawler'] ?? false);
+ } else {
+ $this->dbMock->begin->never()->called();
+ $this->dbMock->subscriptionAdd->never()->called();
+ }
+ if ($in3) {
+ $props = [
+ 'folder' => $in['category_id'] - 1,
+ 'scrape' => $in['crawler'] ?? false,
+ ];
+ $this->dbMock->subscriptionPropertiesSet->calledWith("john.doe@example.com", $out2, $props);
+ if (!$out3 instanceof \Exception) {
+ $this->transaction->commit->called();
+ }
+ } else {
+ $this->dbMock->subscriptionPropertiesSet->never()->called();
+ }
+ if ($in4) {
+ $rules = [
+ 'keep_rule' => $in['keeplist_rules'] ?? null,
+ 'block_rule' => $in['blocklist_rules'] ?? null,
+ ];
+ $this->dbMock->subscriptionPropertiesSet->calledWith("john.doe@example.com", $out2, $rules);
+ } else {
+ $this->dbMock->subscriptionPropertiesSet->atMost(1)->called();
+ }
+ }
+
+ public function provideFeedCreations(): iterable {
+ self::clearData();
+ return [
+ [['category_id' => 1], null, null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "feed_url"], 422)],
+ [['feed_url' => "http://example.com/"], null, null, null, null, new ErrorResponse(["MissingInputValue", 'field' => "category_id"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => "1"], null, null, null, null, new ErrorResponse(["InvalidInputType", 'field' => "category_id", 'expected' => "integer", 'actual' => "string"], 422)],
+ [['feed_url' => "Not a URL", 'category_id' => 1], null, null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "feed_url"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 0], null, null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "category_id"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1, 'keeplist_rules' => "["], null, null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "keeplist_rules"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1, 'blocklist_rules' => "["], null, null, null, null, new ErrorResponse(["InvalidInputValue", 'field' => "blocklist_rules"], 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("internalError"), null, null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("invalidCertificate"), null, null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("invalidUrl"), null, null, null, new ErrorResponse("Fetch404", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("maxRedirect"), null, null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("maxSize"), null, null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("timeout"), null, null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("forbidden"), null, null, null, new ErrorResponse("Fetch403", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("unauthorized"), null, null, null, new ErrorResponse("Fetch401", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("transmissionError"), null, null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("connectionFailed"), null, null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("malformedXml"), null, null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("xmlEntity"), null, null, null, new ErrorResponse("FetchOther", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("subscriptionNotFound"), null, null, null, new ErrorResponse("Fetch404", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], new FeedException("unsupportedFeedFormat"), null, null, null, new ErrorResponse("FetchFormat", 502)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], 2112, new ExceptionInput("constraintViolation"), null, null, new ErrorResponse("DuplicateFeed", 409)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], 2112, 44, new ExceptionInput("idMissing"), null, new ErrorResponse("MissingCategory", 422)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1], 2112, 44, true, null, new Response(['feed_id' => 44], 201)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1, 'keeplist_rules' => "^A"], 2112, 44, true, true, new Response(['feed_id' => 44], 201)],
+ [['feed_url' => "http://example.com/", 'category_id' => 1, 'blocklist_rules' => "A"], 2112, 44, true, true, new Response(['feed_id' => 44], 201)],
+ ];
+ }
+
+ /** @dataProvider provideFeedModifications */
+ public function testModifyAFeed(array $in, array $data, $out, ResponseInterface $exp): void {
+ $this->h = $this->partialMock(V1::class);
+ $this->h->getFeed->returns(new Response(self::FEEDS_OUT[0]));
+ if ($out instanceof \Exception) {
+ $this->dbMock->subscriptionPropertiesSet->throws($out);
+ } else {
+ $this->dbMock->subscriptionPropertiesSet->returns($out);
+ }
+ $this->assertMessage($exp, $this->req("PUT", "/feeds/2112", $in));
+ $this->dbMock->subscriptionPropertiesSet->calledWith(Arsse::$user->id, 2112, $data);
+ }
+
+ public function provideFeedModifications(): iterable {
+ self::clearData();
+ $success = new Response(self::FEEDS_OUT[0], 201);
+ return [
+ [[], [], true, $success],
+ [[], [], new ExceptionInput("subjectMissing"), new ErrorResponse("404", 404)],
+ [['title' => ""], ['title' => ""], new ExceptionInput("missing"), new ErrorResponse("InvalidTitle", 422)],
+ [['title' => " "], ['title' => " "], new ExceptionInput("whitespace"), new ErrorResponse("InvalidTitle", 422)],
+ [['title' => " "], ['title' => " "], new ExceptionInput("whitespace"), new ErrorResponse("InvalidTitle", 422)],
+ [['category_id' => 47], ['folder' => 46], new ExceptionInput("idMissing"), new ErrorResponse("MissingCategory", 422)],
+ [['crawler' => false], ['scrape' => false], true, $success],
+ [['keeplist_rules' => ""], ['keep_rule' => ""], true, $success],
+ [['blocklist_rules' => "ook"], ['block_rule' => "ook"], true, $success],
+ [['title' => "Ook!", 'crawler' => true], ['title' => "Ook!", 'scrape' => true], true, $success],
+ ];
+ }
+
+ public function testModifyAFeedWithNoBody(): void {
+ $this->h = $this->partialMock(V1::class);
+ $this->h->getFeed->returns(new Response(self::FEEDS_OUT[0]));
+ $this->dbMock->subscriptionPropertiesSet->returns(true);
+ $this->assertMessage(new Response(self::FEEDS_OUT[0], 201), $this->req("PUT", "/feeds/2112", ""));
+ $this->dbMock->subscriptionPropertiesSet->calledWith(Arsse::$user->id, 2112, []);
+ }
+
+ public function testDeleteAFeed(): void {
+ $this->dbMock->subscriptionRemove->returns(true);
+ $this->assertMessage(new EmptyResponse(204), $this->req("DELETE", "/feeds/2112"));
+ $this->dbMock->subscriptionRemove->calledWith(Arsse::$user->id, 2112);
+ }
+
+ public function testDeleteAMissingFeed(): void {
+ $this->dbMock->subscriptionRemove->throws(new ExceptionInput("subjectMissing"));
+ $this->assertMessage(new ErrorResponse("404", 404), $this->req("DELETE", "/feeds/2112"));
+ $this->dbMock->subscriptionRemove->calledWith(Arsse::$user->id, 2112);
+ }
+
+ /** @dataProvider provideIcons */
+ public function testGetTheIconOfASubscription($out, ResponseInterface $exp): void {
+ if ($out instanceof \Exception) {
+ $this->dbMock->subscriptionIcon->throws($out);
+ } else {
+ $this->dbMock->subscriptionIcon->returns($this->v($out));
+ }
+ $this->assertMessage($exp, $this->req("GET", "/feeds/2112/icon"));
+ $this->dbMock->subscriptionIcon->calledWith(Arsse::$user->id, 2112);
+ }
+
+ public function provideIcons(): iterable {
+ return [
+ [['id' => 44, 'type' => "image/svg+xml", 'data' => " "], new Response(['id' => 44, 'data' => "image/svg+xml;base64,PHN2Zy8+", 'mime_type' => "image/svg+xml"])],
+ [['id' => 47, 'type' => "", 'data' => " "], new ErrorResponse("404", 404)],
+ [['id' => 47, 'type' => null, 'data' => " "], new ErrorResponse("404", 404)],
+ [['id' => 47, 'type' => null, 'data' => null], new ErrorResponse("404", 404)],
+ [null, new ErrorResponse("404", 404)],
+ [new ExceptionInput("subjectMissing"), new ErrorResponse("404", 404)],
+ ];
+ }
+
+ /** @dataProvider provideEntryQueries */
+ public function testGetEntries(string $url, ?Context $c, ?array $order, $out, bool $count, ResponseInterface $exp): void {
+ $this->dbMock->subscriptionList->returns(new Result($this->v(self::FEEDS)));
+ $this->dbMock->articleCount->returns(2112);
+ if ($out instanceof \Exception) {
+ $this->dbMock->articleList->throws($out);
+ } else {
+ $this->dbMock->articleList->returns(new Result($this->v($out)));
+ }
+ $this->assertMessage($exp, $this->req("GET", $url));
+ if ($c) {
+ $this->dbMock->articleList->calledWith(Arsse::$user->id, $this->equalTo($c), array_keys(self::ENTRIES[0]), $order);
+ } else {
+ $this->dbMock->articleList->never()->called();
+ }
+ if ($out && !$out instanceof \Exception) {
+ $this->dbMock->subscriptionList->calledWith(Arsse::$user->id);
+ } else {
+ $this->dbMock->subscriptionList->never()->called();
+ }
+ if ($count) {
+ $this->dbMock->articleCount->calledWith(Arsse::$user->id, $this->equalTo((clone $c)->limit(0)->offset(0)));
+ } else {
+ $this->dbMock->articleCount->never()->called();
+ }
+ }
+
+ public function provideEntryQueries(): iterable {
+ self::clearData();
+ $c = (new Context)->limit(100);
+ $o = ["modified_date"]; // the default sort order
+ return [
+ ["/entries?after=A", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "after"], 400)],
+ ["/entries?before=B", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "before"], 400)],
+ ["/entries?category_id=0", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "category_id"], 400)],
+ ["/entries?after_entry_id=0", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "after_entry_id"], 400)],
+ ["/entries?before_entry_id=0", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "before_entry_id"], 400)],
+ ["/entries?limit=-1", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "limit"], 400)],
+ ["/entries?offset=-1", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "offset"], 400)],
+ ["/entries?direction=sideways", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "direction"], 400)],
+ ["/entries?order=false", null, null, [], false, new ErrorResponse(["InvalidInputValue", 'field' => "order"], 400)],
+ ["/entries?starred&starred", null, null, [], false, new ErrorResponse(["DuplicateInputValue", 'field' => "starred"], 400)],
+ ["/entries?after&after=0", null, null, [], false, new ErrorResponse(["DuplicateInputValue", 'field' => "after"], 400)],
+ ["/entries", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?category_id=47", (clone $c)->folder(46), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?category_id=1", (clone $c)->folderShallow(0), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=unread", (clone $c)->unread(true)->hidden(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=read", (clone $c)->unread(false)->hidden(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=removed", (clone $c)->hidden(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=unread&status=read", (clone $c)->hidden(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=unread&status=removed", (clone $c)->unread(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=removed&status=read", (clone $c)->unread(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=removed&status=read&status=removed", (clone $c)->unread(false), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?status=removed&status=read&status=unread", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?starred", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?starred=", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?starred=true", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?starred=false", (clone $c)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?after=0", (clone $c)->modifiedSince(0), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?before=0", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?before=1", (clone $c)->notModifiedSince(1), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?after_entry_id=42", (clone $c)->oldestArticle(43), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?before_entry_id=47", (clone $c)->latestArticle(46), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?search=alpha%20beta", (clone $c)->searchTerms(["alpha", "beta"]), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?limit=4", (clone $c)->limit(4), $o, self::ENTRIES, true, new Response(['total' => 2112, 'entries' => self::ENTRIES_OUT])],
+ ["/entries?offset=20", (clone $c)->offset(20), $o, [], true, new Response(['total' => 2112, 'entries' => []])],
+ ["/entries?direction=asc", $c, $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=id", $c, ["id"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=published_at", $c, ["modified_date"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=category_id", $c, ["top_folder"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=category_title", $c, ["top_folder_name"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=status", $c, ["hidden", "unread desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?direction=desc", $c, ["modified_date desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=id&direction=desc", $c, ["id desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=published_at&direction=desc", $c, ["modified_date desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=category_id&direction=desc", $c, ["top_folder desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=category_title&direction=desc", $c, ["top_folder_name desc"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?order=status&direction=desc", $c, ["hidden desc", "unread"], self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/entries?category_id=2112", (clone $c)->folder(2111), $o, new ExceptionInput("idMissing"), false, new ErrorResponse("MissingCategory")],
+ ["/feeds/42/entries", (clone $c)->subscription(42), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/feeds/42/entries?category_id=47", (clone $c)->subscription(42)->folder(46), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/feeds/2112/entries", (clone $c)->subscription(2112), $o, new ExceptionInput("idMissing"), false, new ErrorResponse("404", 404)],
+ ["/categories/42/entries", (clone $c)->folder(41), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/categories/42/entries?category_id=47", (clone $c)->folder(41), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/categories/42/entries?starred", (clone $c)->folder(41)->starred(true), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/categories/1/entries", (clone $c)->folderShallow(0), $o, self::ENTRIES, false, new Response(['total' => sizeof(self::ENTRIES_OUT), 'entries' => self::ENTRIES_OUT])],
+ ["/categories/2112/entries", (clone $c)->folder(2111), $o, new ExceptionInput("idMissing"), false, new ErrorResponse("404", 404)],
+ ];
+ }
+
+ /** @dataProvider provideSingleEntryQueries */
+ public function testGetASingleEntry(string $url, Context $c, $out, ResponseInterface $exp): void {
+ $this->dbMock->subscriptionPropertiesGet->returns($this->v(self::FEEDS[1]));
+ if ($out instanceof \Exception) {
+ $this->dbMock->articleList->throws($out);
+ } else {
+ $this->dbMock->articleList->returns(new Result($this->v($out)));
+ }
+ $this->assertMessage($exp, $this->req("GET", $url));
+ if ($c) {
+ $this->dbMock->articleList->calledWith(Arsse::$user->id, $this->equalTo($c), array_keys(self::ENTRIES[0]));
+ } else {
+ $this->dbMock->articleList->never()->called();
+ }
+ if ($out && is_array($out)) {
+ $this->dbMock->subscriptionPropertiesGet->calledWith(Arsse::$user->id, 55);
+ } else {
+ $this->dbMock->subscriptionList->never()->called();
+ }
+ }
+
+ public function provideSingleEntryQueries(): iterable {
+ self::clearData();
+ $c = new Context;
+ return [
+ ["/entries/42", (clone $c)->article(42), [self::ENTRIES[1]], new Response(self::ENTRIES_OUT[1])],
+ ["/entries/2112", (clone $c)->article(2112), new ExceptionInput("subjectMissing"), new ErrorResponse("404", 404)],
+ ["/feeds/47/entries/42", (clone $c)->subscription(47)->article(42), [self::ENTRIES[1]], new Response(self::ENTRIES_OUT[1])],
+ ["/feeds/47/entries/44", (clone $c)->subscription(47)->article(44), [], new ErrorResponse("404", 404)],
+ ["/feeds/47/entries/2112", (clone $c)->subscription(47)->article(2112), new ExceptionInput("subjectMissing"), new ErrorResponse("404", 404)],
+ ["/feeds/2112/entries/47", (clone $c)->subscription(2112)->article(47), new ExceptionInput("idMissing"), new ErrorResponse("404", 404)],
+ ["/categories/47/entries/42", (clone $c)->folder(46)->article(42), [self::ENTRIES[1]], new Response(self::ENTRIES_OUT[1])],
+ ["/categories/47/entries/44", (clone $c)->folder(46)->article(44), [], new ErrorResponse("404", 404)],
+ ["/categories/47/entries/2112", (clone $c)->folder(46)->article(2112), new ExceptionInput("subjectMissing"), new ErrorResponse("404", 404)],
+ ["/categories/2112/entries/47", (clone $c)->folder(2111)->article(47), new ExceptionInput("idMissing"), new ErrorResponse("404", 404)],
+ ["/categories/1/entries/42", (clone $c)->folderShallow(0)->article(42), [self::ENTRIES[1]], new Response(self::ENTRIES_OUT[1])],
+ ];
+ }
+
+ /** @dataProvider provideEntryMarkings */
+ public function testMarkEntries(array $in, ?array $data, ResponseInterface $exp): void {
+ $this->dbMock->articleMark->returns(0);
+ $this->assertMessage($exp, $this->req("PUT", "/entries", $in));
+ if ($data) {
+ $this->dbMock->articleMark->calledWith(Arsse::$user->id, $data, (new Context)->articles($in['entry_ids']));
+ } else {
+ $this->dbMock->articleMark->never()->called();
+ }
+ }
+
+ public function provideEntryMarkings(): iterable {
+ self::clearData();
+ return [
+ [['status' => "read"], null, new ErrorResponse(["MissingInputValue", 'field' => "entry_ids"], 422)],
+ [['entry_ids' => [1]], null, new ErrorResponse(["MissingInputValue", 'field' => "status"], 422)],
+ [['entry_ids' => [], 'status' => "read"], null, new ErrorResponse(["MissingInputValue", 'field' => "entry_ids"], 422)],
+ [['entry_ids' => 1, 'status' => "read"], null, new ErrorResponse(["InvalidInputType", 'field' => "entry_ids", 'expected' => "array", 'actual' => "integer"], 422)],
+ [['entry_ids' => ["1"], 'status' => "read"], null, new ErrorResponse(["InvalidInputType", 'field' => "entry_ids", 'expected' => "integer", 'actual' => "string"], 422)],
+ [['entry_ids' => [1], 'status' => 1], null, new ErrorResponse(["InvalidInputType", 'field' => "status", 'expected' => "string", 'actual' => "integer"], 422)],
+ [['entry_ids' => [0], 'status' => "read"], null, new ErrorResponse(["InvalidInputValue", 'field' => "entry_ids"], 422)],
+ [['entry_ids' => [1], 'status' => "reread"], null, new ErrorResponse(["InvalidInputValue", 'field' => "status"], 422)],
+ [['entry_ids' => [1, 2], 'status' => "read"], ['read' => true, 'hidden' => false], new EmptyResponse(204)],
+ [['entry_ids' => [1, 2], 'status' => "unread"], ['read' => false, 'hidden' => false], new EmptyResponse(204)],
+ [['entry_ids' => [1, 2], 'status' => "removed"], ['read' => true, 'hidden' => true], new EmptyResponse(204)],
+ ];
+ }
+
+ /** @dataProvider provideMassMarkings */
+ public function testMassMarkEntries(string $url, Context $c, $out, ResponseInterface $exp): void {
+ if ($out instanceof \Exception) {
+ $this->dbMock->articleMark->throws($out);
+ } else {
+ $this->dbMock->articleMark->returns($out);
+ }
+ $this->assertMessage($exp, $this->req("PUT", $url));
+ if ($out !== null) {
+ $this->dbMock->articleMark->calledWith(Arsse::$user->id, ['read' => true], $this->equalTo($c));
+ } else {
+ $this->dbMock->articleMark->never()->called();
+ }
+ }
+
+ public function provideMassMarkings(): iterable {
+ self::clearData();
+ $c = (new Context)->hidden(false);
+ return [
+ ["/users/42/mark-all-as-read", $c, 1123, new EmptyResponse(204)],
+ ["/users/2112/mark-all-as-read", $c, null, new ErrorResponse("403", 403)],
+ ["/feeds/47/mark-all-as-read", (clone $c)->subscription(47), 2112, new EmptyResponse(204)],
+ ["/feeds/2112/mark-all-as-read", (clone $c)->subscription(2112), new ExceptionInput("idMissing"), new ErrorResponse("404", 404)],
+ ["/categories/47/mark-all-as-read", (clone $c)->folder(46), 1337, new EmptyResponse(204)],
+ ["/categories/2112/mark-all-as-read", (clone $c)->folder(2111), new ExceptionInput("idMissing"), new ErrorResponse("404", 404)],
+ ["/categories/1/mark-all-as-read", (clone $c)->folderShallow(0), 6666, new EmptyResponse(204)],
+ ];
+ }
+
+ /** @dataProvider provideBookmarkTogglings */
+ public function testToggleABookmark($before, ?bool $after, ResponseInterface $exp): void {
+ $c = (new Context)->article(2112);
+ $this->dbMock->articleMark->returns(1);
+ if ($before instanceof \Exception) {
+ $this->dbMock->articleCount->throws($before);
+ } else {
+ $this->dbMock->articleCount->returns($before);
+ }
+ $this->assertMessage($exp, $this->req("PUT", "/entries/2112/bookmark"));
+ if ($after !== null) {
+ Phony::inOrder(
+ $this->dbMock->begin->calledWith(),
+ $this->dbMock->articleCount->calledWith(Arsse::$user->id, (clone $c)->starred(false)),
+ $this->dbMock->articleMark->calledWith(Arsse::$user->id, ['starred' => $after], $c),
+ $this->transaction->commit->called()
+ );
+ } else {
+ Phony::inOrder(
+ $this->dbMock->begin->calledWith(),
+ $this->dbMock->articleCount->calledWith(Arsse::$user->id, (clone $c)->starred(false))
+ );
+ $this->dbMock->articleMark->never()->called();
+ $this->transaction->commit->never()->called();
+ }
+ }
+
+ public function provideBookmarkTogglings(): iterable {
+ self::clearData();
+ return [
+ [1, true, new EmptyResponse(204)],
+ [0, false, new EmptyResponse(204)],
+ [new ExceptionInput("subjectMissing"), null, new ErrorResponse("404", 404)],
+ ];
+ }
+
+ public function testRefreshAFeed(): void {
+ $this->dbMock->subscriptionPropertiesGet->returns([]);
+ $this->assertMessage(new EmptyResponse(204), $this->req("PUT", "/feeds/47/refresh"));
+ $this->dbMock->subscriptionPropertiesGet->calledWith(Arsse::$user->id, 47);
+ }
+
+ public function testRefreshAMissingFeed(): void {
+ $this->dbMock->subscriptionPropertiesGet->throws(new ExceptionInput("subjectMissing"));
+ $this->assertMessage(new ErrorResponse("404", 404), $this->req("PUT", "/feeds/2112/refresh"));
+ $this->dbMock->subscriptionPropertiesGet->calledWith(Arsse::$user->id, 2112);
+ }
+
+ public function testRefreshAllFeeds(): void {
+ $this->assertMessage(new EmptyResponse(204), $this->req("PUT", "/feeds/refresh"));
+ }
+
+ /** @dataProvider provideImports */
+ public function testImport($out, ResponseInterface $exp): void {
+ $opml = $this->mock(OPML::class);
+ $this->objMock->get->with(OPML::class)->returns($opml);
+ $action = ($out instanceof \Exception) ? "throws" : "returns";
+ $opml->import->$action($out);
+ $this->assertMessage($exp, $this->req("POST", "/import", "IMPORT DATA"));
+ $opml->import->calledWith(Arsse::$user->id, "IMPORT DATA");
+ }
+
+ public function provideImports(): iterable {
+ self::clearData();
+ return [
+ [new ImportException("invalidSyntax"), new ErrorResponse("InvalidBodyXML", 400)],
+ [new ImportException("invalidSemantics"), new ErrorResponse("InvalidBodyOPML", 422)],
+ [new ImportException("invalidFolderName"), new ErrorResponse("InvalidImportCategory", 422)],
+ [new ImportException("invalidFolderCopy"), new ErrorResponse("DuplicateImportCategory", 422)],
+ [new ImportException("invalidTagName"), new ErrorResponse("InvalidImportLabel", 422)],
+ [new FeedException("invalidUrl", ['url' => "http://example.com/"]), new ErrorResponse(["FailedImportFeed", 'url' => "http://example.com/", 'code' => 10502], 502)],
+ [true, new Response(['message' => Arsse::$lang->msg("API.Miniflux.ImportSuccess")])],
+ ];
+ }
+
+ public function testExport(): void {
+ $opml = $this->mock(OPML::class);
+ $this->objMock->get->with(OPML::class)->returns($opml);
+ $opml->export->returns("EXPORT DATA");
+ $this->assertMessage(new TextResponse("EXPORT DATA", 200, ['Content-Type' => "application/xml"]), $this->req("GET", "/export"));
+ $opml->export->calledWith(Arsse::$user->id);
+ }
+}
diff --git a/tests/cases/REST/NextcloudNews/TestV1_2.php b/tests/cases/REST/NextcloudNews/TestV1_2.php
index 5e8c7d13..9e980e99 100644
--- a/tests/cases/REST/NextcloudNews/TestV1_2.php
+++ b/tests/cases/REST/NextcloudNews/TestV1_2.php
@@ -23,12 +23,13 @@ use Laminas\Diactoros\Response\EmptyResponse;
class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
protected $h;
protected $transaction;
+ protected $userId;
protected $feeds = [ // expected sample output of a feed list from the database, and the resultant expected transformation by the REST handler
'db' => [
[
'id' => 2112,
'url' => 'http://example.com/news.atom',
- 'favicon' => 'http://example.com/favicon.png',
+ 'icon_url' => 'http://example.com/favicon.png',
'source' => 'http://example.com/',
'folder' => null,
'top_folder' => null,
@@ -43,7 +44,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
[
'id' => 42,
'url' => 'http://example.org/news.atom',
- 'favicon' => 'http://example.org/favicon.png',
+ 'icon_url' => 'http://example.org/favicon.png',
'source' => 'http://example.org/',
'folder' => 12,
'top_folder' => 8,
@@ -58,7 +59,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
[
'id' => 47,
'url' => 'http://example.net/news.atom',
- 'favicon' => 'http://example.net/favicon.png',
+ 'icon_url' => 'http://example.net/favicon.png',
'source' => 'http://example.net/',
'folder' => null,
'top_folder' => null,
@@ -299,6 +300,10 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
];
protected function req(string $method, string $target, $data = "", array $headers = [], bool $authenticated = true, bool $body = true): ResponseInterface {
+ Arsse::$obj = $this->objMock->get();
+ Arsse::$db = $this->dbMock->get();
+ Arsse::$user = $this->userMock->get();
+ Arsse::$user->id = $this->userId;
$prefix = "/index.php/apps/news/api/v1-2";
$url = $prefix.$target;
if ($body) {
@@ -312,23 +317,20 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function setUp(): void {
- self::clearData();
+ parent::setUp();
self::setConf();
// create a mock user manager
- Arsse::$user = \Phake::mock(User::class);
- Arsse::$user->id = "john.doe@example.com";
+ $this->userId = "john.doe@example.com";
+ $this->userMock = $this->mock(User::class);
+ $this->userMock->auth->returns(true);
+ $this->userMock->propertiesGet->returns(['admin' => true]);
// create a mock database interface
- Arsse::$db = \Phake::mock(Database::class);
- $this->transaction = \Phake::mock(Transaction::class);
- \Phake::when(Arsse::$db)->begin->thenReturn($this->transaction);
+ $this->dbMock = $this->mock(Database::class);
+ $this->dbMock->begin->returns($this->mock(Transaction::class));
//initialize a handler
$this->h = new V1_2();
}
- public function tearDown(): void {
- self::clearData();
- }
-
protected function v($value) {
return $value;
}
@@ -405,7 +407,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
['id' => 1, 'name' => "Software"],
['id' => 12, 'name' => "Hardware"],
];
- \Phake::when(Arsse::$db)->folderList(Arsse::$user->id, null, false)->thenReturn(new Result($this->v($list)));
+ $this->dbMock->folderList->with($this->userId, null, false)->returns(new Result($this->v($list)));
$exp = new Response(['folders' => $out]);
$this->assertMessage($exp, $this->req("GET", "/folders"));
}
@@ -413,18 +415,18 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideFolderCreations */
public function testAddAFolder(array $input, bool $body, $output, ResponseInterface $exp): void {
if ($output instanceof ExceptionInput) {
- \Phake::when(Arsse::$db)->folderAdd->thenThrow($output);
+ $this->dbMock->folderAdd->throws($output);
} else {
- \Phake::when(Arsse::$db)->folderAdd->thenReturn($output);
- \Phake::when(Arsse::$db)->folderPropertiesGet->thenReturn($this->v(['id' => $output, 'name' => $input['name'], 'parent' => null]));
+ $this->dbMock->folderAdd->returns($output);
+ $this->dbMock->folderPropertiesGet->returns($this->v(['id' => $output, 'name' => $input['name'], 'parent' => null]));
}
$act = $this->req("POST", "/folders", $input, [], true, $body);
$this->assertMessage($exp, $act);
- \Phake::verify(Arsse::$db)->folderAdd(Arsse::$user->id, $input);
+ $this->dbMock->folderAdd->calledWith($this->userId, $input);
if ($output instanceof ExceptionInput) {
- \Phake::verify(Arsse::$db, \Phake::times(0))->folderPropertiesGet;
+ $this->dbMock->folderPropertiesGet->never()->called();
} else {
- \Phake::verify(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, $output);
+ $this->dbMock->folderPropertiesGet->calledWith($this->userId, $this->equalTo($output));
}
}
@@ -442,25 +444,25 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testRemoveAFolder(): void {
- \Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, 1)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing"));
+ $this->dbMock->folderRemove->with($this->userId, 1)->returns(true)->throws(new ExceptionInput("subjectMissing"));
$exp = new EmptyResponse(204);
$this->assertMessage($exp, $this->req("DELETE", "/folders/1"));
// fail on the second invocation because it no longer exists
$exp = new EmptyResponse(404);
$this->assertMessage($exp, $this->req("DELETE", "/folders/1"));
- \Phake::verify(Arsse::$db, \Phake::times(2))->folderRemove(Arsse::$user->id, 1);
+ $this->dbMock->folderRemove->times(2)->calledWith($this->userId, 1);
}
/** @dataProvider provideFolderRenamings */
public function testRenameAFolder(array $input, int $id, $output, ResponseInterface $exp): void {
if ($output instanceof ExceptionInput) {
- \Phake::when(Arsse::$db)->folderPropertiesSet->thenThrow($output);
+ $this->dbMock->folderPropertiesSet->throws($output);
} else {
- \Phake::when(Arsse::$db)->folderPropertiesSet->thenReturn($output);
+ $this->dbMock->folderPropertiesSet->returns($output);
}
$act = $this->req("PUT", "/folders/$id", $input);
$this->assertMessage($exp, $act);
- \Phake::verify(Arsse::$db)->folderPropertiesSet(Arsse::$user->id, $id, $input);
+ $this->dbMock->folderPropertiesSet->calledWith($this->userId, $id, $input);
}
public function provideFolderRenamings(): array {
@@ -492,9 +494,9 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
'starredCount' => 5,
'newestItemId' => 4758915,
];
- \Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result([]))->thenReturn(new Result($this->v($this->feeds['db'])));
- \Phake::when(Arsse::$db)->articleStarred(Arsse::$user->id)->thenReturn($this->v(['total' => 0]))->thenReturn($this->v(['total' => 5]));
- \Phake::when(Arsse::$db)->editionLatest(Arsse::$user->id)->thenReturn(0)->thenReturn(4758915);
+ $this->dbMock->subscriptionList->with($this->userId)->returns(new Result([]))->returns(new Result($this->v($this->feeds['db'])));
+ $this->dbMock->articleStarred->with($this->userId)->returns($this->v(['total' => 0]))->returns($this->v(['total' => 5]));
+ $this->dbMock->editionLatest->with($this->userId)->returns(0)->returns(4758915);
$exp = new Response($exp1);
$this->assertMessage($exp, $this->req("GET", "/feeds"));
$exp = new Response($exp2);
@@ -504,37 +506,37 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideNewSubscriptions */
public function testAddASubscription(array $input, $id, int $latestEdition, array $output, $moveOutcome, ResponseInterface $exp): void {
if ($id instanceof \Exception) {
- \Phake::when(Arsse::$db)->subscriptionAdd->thenThrow($id);
+ $this->dbMock->subscriptionAdd->throws($id);
} else {
- \Phake::when(Arsse::$db)->subscriptionAdd->thenReturn($id);
+ $this->dbMock->subscriptionAdd->returns($id);
}
if ($moveOutcome instanceof \Exception) {
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet->thenThrow($moveOutcome);
+ $this->dbMock->subscriptionPropertiesSet->throws($moveOutcome);
} else {
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet->thenReturn($moveOutcome);
+ $this->dbMock->subscriptionPropertiesSet->returns($moveOutcome);
}
- \Phake::when(Arsse::$db)->subscriptionPropertiesGet->thenReturn($this->v($output));
- \Phake::when(Arsse::$db)->editionLatest->thenReturn($latestEdition);
+ $this->dbMock->subscriptionPropertiesGet->returns($this->v($output));
+ $this->dbMock->editionLatest->returns($latestEdition);
$act = $this->req("POST", "/feeds", $input);
$this->assertMessage($exp, $act);
- \Phake::verify(Arsse::$db)->subscriptionAdd(Arsse::$user->id, $input['url'] ?? "");
+ $this->dbMock->subscriptionAdd->calledWith($this->userId, $input['url'] ?? "");
if ($id instanceof \Exception) {
- \Phake::verify(Arsse::$db, \Phake::times(0))->subscriptionPropertiesSet;
- \Phake::verify(Arsse::$db, \Phake::times(0))->subscriptionPropertiesGet;
- \Phake::verify(Arsse::$db, \Phake::times(0))->editionLatest;
+ $this->dbMock->subscriptionPropertiesSet->never()->called();
+ $this->dbMock->subscriptionPropertiesGet->never()->called();
+ $this->dbMock->editionLatest->never()->called();
} else {
- \Phake::verify(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, $id);
- \Phake::verify(Arsse::$db)->editionLatest(Arsse::$user->id, (new Context)->subscription($id));
+ $this->dbMock->subscriptionPropertiesGet->calledWith($this->userId, $id);
+ $this->dbMock->editionLatest->calledWith($this->userId, $this->equalTo((new Context)->subscription($id)->hidden(false)));
if ($input['folderId'] ?? 0) {
- \Phake::verify(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, $id, ['folder' => (int) $input['folderId']]);
+ $this->dbMock->subscriptionPropertiesSet->calledWith($this->userId, $id, ['folder' => (int) $input['folderId']]);
} else {
- \Phake::verify(Arsse::$db, \Phake::times(0))->subscriptionPropertiesSet;
+ $this->dbMock->subscriptionPropertiesSet->never()->called();
}
}
}
public function provideNewSubscriptions(): array {
- $feedException = new \JKingWeb\Arsse\Feed\Exception("", new \PicoFeed\Reader\SubscriptionNotFoundException);
+ $feedException = new \JKingWeb\Arsse\Feed\Exception("", [], new \PicoFeed\Reader\SubscriptionNotFoundException);
return [
[['url' => "http://example.com/news.atom", 'folderId' => 3], 2112, 0, $this->feeds['db'][0], new ExceptionInput("idMissing"), new Response(['feeds' => [$this->feeds['rest'][0]]])],
[['url' => "http://example.org/news.atom", 'folderId' => 8], 42, 4758915, $this->feeds['db'][1], true, new Response(['feeds' => [$this->feeds['rest'][1]], 'newestItemId' => 4758915])],
@@ -546,13 +548,13 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testRemoveASubscription(): void {
- \Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 1)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing"));
+ $this->dbMock->subscriptionRemove->with($this->userId, 1)->returns(true)->throws(new ExceptionInput("subjectMissing"));
$exp = new EmptyResponse(204);
$this->assertMessage($exp, $this->req("DELETE", "/feeds/1"));
// fail on the second invocation because it no longer exists
$exp = new EmptyResponse(404);
$this->assertMessage($exp, $this->req("DELETE", "/feeds/1"));
- \Phake::verify(Arsse::$db, \Phake::times(2))->subscriptionRemove(Arsse::$user->id, 1);
+ $this->dbMock->subscriptionRemove->times(2)->calledWith($this->userId, 1);
}
public function testMoveASubscription(): void {
@@ -564,11 +566,11 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
['folderId' => -1],
[],
];
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => 42])->thenReturn(true);
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => null])->thenReturn(true);
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => 2112])->thenThrow(new ExceptionInput("idMissing")); // folder does not exist
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, ['folder' => -1])->thenThrow(new ExceptionInput("typeViolation")); // folder is invalid
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, $this->anything())->thenThrow(new ExceptionInput("subjectMissing")); // subscription does not exist
+ $this->dbMock->subscriptionPropertiesSet->with($this->userId, 1, ['folder' => 42])->returns(true);
+ $this->dbMock->subscriptionPropertiesSet->with($this->userId, 1, ['folder' => null])->returns(true);
+ $this->dbMock->subscriptionPropertiesSet->with($this->userId, 1, ['folder' => 2112])->throws(new ExceptionInput("idMissing")); // folder does not exist
+ $this->dbMock->subscriptionPropertiesSet->with($this->userId, 1, ['folder' => -1])->throws(new ExceptionInput("typeViolation")); // folder is invalid
+ $this->dbMock->subscriptionPropertiesSet->with($this->userId, 42, $this->anything())->throws(new ExceptionInput("subjectMissing")); // subscription does not exist
$exp = new EmptyResponse(204);
$this->assertMessage($exp, $this->req("PUT", "/feeds/1/move", json_encode($in[0])));
$exp = new EmptyResponse(204);
@@ -593,12 +595,12 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
['feedTitle' => "Feed does not exist"],
[],
];
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, $this->identicalTo(['title' => null]))->thenReturn(true);
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, $this->identicalTo(['title' => "Ook"]))->thenReturn(true);
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, $this->identicalTo(['title' => " "]))->thenThrow(new ExceptionInput("whitespace"));
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, $this->identicalTo(['title' => ""]))->thenThrow(new ExceptionInput("missing"));
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 1, $this->identicalTo(['title' => false]))->thenThrow(new ExceptionInput("missing"));
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 42, $this->anything())->thenThrow(new ExceptionInput("subjectMissing"));
+ $this->dbMock->subscriptionPropertiesSet->with($this->userId, 1, $this->identicalTo(['title' => null]))->returns(true);
+ $this->dbMock->subscriptionPropertiesSet->with($this->userId, 1, $this->identicalTo(['title' => "Ook"]))->returns(true);
+ $this->dbMock->subscriptionPropertiesSet->with($this->userId, 1, $this->identicalTo(['title' => " "]))->throws(new ExceptionInput("whitespace"));
+ $this->dbMock->subscriptionPropertiesSet->with($this->userId, 1, $this->identicalTo(['title' => ""]))->throws(new ExceptionInput("missing"));
+ $this->dbMock->subscriptionPropertiesSet->with($this->userId, 1, $this->identicalTo(['title' => false]))->throws(new ExceptionInput("missing"));
+ $this->dbMock->subscriptionPropertiesSet->with($this->userId, 42, $this->anything())->throws(new ExceptionInput("subjectMissing"));
$exp = new EmptyResponse(422);
$this->assertMessage($exp, $this->req("PUT", "/feeds/1/rename", json_encode($in[0])));
$exp = new EmptyResponse(204);
@@ -624,11 +626,18 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
'userId' => "",
],
];
- \Phake::when(Arsse::$db)->feedListStale->thenReturn($this->v(array_column($out, "id")));
+ $this->dbMock->feedListStale->returns($this->v(array_column($out, "id")));
$exp = new Response(['feeds' => $out]);
$this->assertMessage($exp, $this->req("GET", "/feeds/all"));
}
+ public function testListStaleFeedsWithoutAuthority(): void {
+ $this->userMock->propertiesGet->returns(['admin' => false]);
+ $exp = new EmptyResponse(403);
+ $this->assertMessage($exp, $this->req("GET", "/feeds/all"));
+ $this->dbMock->feedListStale->never()->called();
+ }
+
public function testUpdateAFeed(): void {
$in = [
['feedId' => 42], // valid
@@ -637,9 +646,9 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
['feedId' => -1], // invalid ID
['feed' => 42], // invalid input
];
- \Phake::when(Arsse::$db)->feedUpdate(42)->thenReturn(true);
- \Phake::when(Arsse::$db)->feedUpdate(2112)->thenThrow(new ExceptionInput("subjectMissing"));
- \Phake::when(Arsse::$db)->feedUpdate($this->lessThan(1))->thenThrow(new ExceptionInput("typeViolation"));
+ $this->dbMock->feedUpdate->with(42)->returns(true);
+ $this->dbMock->feedUpdate->with(2112)->throws(new ExceptionInput("subjectMissing"));
+ $this->dbMock->feedUpdate->with($this->lessThan(1))->throws(new ExceptionInput("typeViolation"));
$exp = new EmptyResponse(204);
$this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[0])));
$exp = new EmptyResponse(404);
@@ -650,65 +659,67 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertMessage($exp, $this->req("GET", "/feeds/update", json_encode($in[4])));
}
- public function testListArticles(): void {
- $t = new \DateTime;
- $in = [
- ['type' => 0, 'id' => 42], // type=0 => subscription/feed
- ['type' => 1, 'id' => 2112], // type=1 => folder
- ['type' => 0, 'id' => -1], // type=0 => subscription/feed; invalid ID
- ['type' => 1, 'id' => -1], // type=1 => folder; invalid ID
- ['type' => 2, 'id' => 0], // type=2 => starred
- ['type' => 3, 'id' => 0], // type=3 => all (default); base context
- ['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5],
- ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5],
- ['getRead' => true], // base context
- ['getRead' => false],
- ['lastModified' => $t->getTimestamp()],
- ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], // offset=0 should not set the latestEdition context
+ public function testUpdateAFeedWithoutAuthority(): void {
+ $this->userMock->propertiesGet->returns(['admin' => false]);
+ $exp = new EmptyResponse(403);
+ $this->assertMessage($exp, $this->req("GET", "/feeds/update", ['feedId' => 42]));
+ $this->dbMock->feedUpdate->never()->called();
+ }
+
+ /** @dataProvider provideArticleQueries */
+ public function testListArticles(string $url, array $in, Context $c, $out, ResponseInterface $exp): void {
+ if ($out instanceof \Exception) {
+ $this->dbMock->articleList->throws($out);
+ } else {
+ $this->dbMock->articleList->returns($out);
+ }
+ $this->assertMessage($exp, $this->req("GET", $url, $in));
+ $columns = ["edition", "guid", "id", "url", "title", "author", "edited_date", "content", "media_type", "media_url", "subscription", "unread", "starred", "modified_date", "fingerprint"];
+ $order = ($in['oldestFirst'] ?? false) ? "edition" : "edition desc";
+ $this->dbMock->articleList->calledWith($this->userId, $this->equalTo($c), $columns, [$order]);
+ }
+
+ public function provideArticleQueries(): iterable {
+ $c = (new Context)->hidden(false);
+ $t = Date::normalize(time());
+ $out = new Result($this->v($this->articles['db']));
+ $r200 = new Response(['items' => $this->articles['rest']]);
+ $r422 = new EmptyResponse(422);
+ return [
+ ["/items", [], clone $c, $out, $r200],
+ ["/items", ['type' => 0, 'id' => 42], (clone $c)->subscription(42), new ExceptionInput("idMissing"), $r422],
+ ["/items", ['type' => 1, 'id' => 2112], (clone $c)->folder(2112), new ExceptionInput("idMissing"), $r422],
+ ["/items", ['type' => 0, 'id' => -1], (clone $c)->subscription(-1), new ExceptionInput("typeViolation"), $r422],
+ ["/items", ['type' => 1, 'id' => -1], (clone $c)->folder(-1), new ExceptionInput("typeViolation"), $r422],
+ ["/items", ['type' => 2, 'id' => 0], (clone $c)->starred(true), $out, $r200],
+ ["/items", ['type' => 3, 'id' => 0], clone $c, $out, $r200],
+ ["/items", ['getRead' => true], clone $c, $out, $r200],
+ ["/items", ['getRead' => false], (clone $c)->unread(true), $out, $r200],
+ ["/items", ['lastModified' => $t->getTimestamp()], (clone $c)->markedSince($t), $out, $r200],
+ ["/items", ['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5], (clone $c)->oldestEdition(6)->limit(10), $out, $r200],
+ ["/items", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5], (clone $c)->latestEdition(4)->limit(5), $out, $r200],
+ ["/items", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], (clone $c)->limit(5), $out, $r200],
+ ["/items/updated", [], clone $c, $out, $r200],
+ ["/items/updated", ['type' => 0, 'id' => 42], (clone $c)->subscription(42), new ExceptionInput("idMissing"), $r422],
+ ["/items/updated", ['type' => 1, 'id' => 2112], (clone $c)->folder(2112), new ExceptionInput("idMissing"), $r422],
+ ["/items/updated", ['type' => 0, 'id' => -1], (clone $c)->subscription(-1), new ExceptionInput("typeViolation"), $r422],
+ ["/items/updated", ['type' => 1, 'id' => -1], (clone $c)->folder(-1), new ExceptionInput("typeViolation"), $r422],
+ ["/items/updated", ['type' => 2, 'id' => 0], (clone $c)->starred(true), $out, $r200],
+ ["/items/updated", ['type' => 3, 'id' => 0], clone $c, $out, $r200],
+ ["/items/updated", ['getRead' => true], clone $c, $out, $r200],
+ ["/items/updated", ['getRead' => false], (clone $c)->unread(true), $out, $r200],
+ ["/items/updated", ['lastModified' => $t->getTimestamp()], (clone $c)->markedSince($t), $out, $r200],
+ ["/items/updated", ['oldestFirst' => true, 'batchSize' => 10, 'offset' => 5], (clone $c)->oldestEdition(6)->limit(10), $out, $r200],
+ ["/items/updated", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 5], (clone $c)->latestEdition(4)->limit(5), $out, $r200],
+ ["/items/updated", ['oldestFirst' => false, 'batchSize' => 5, 'offset' => 0], (clone $c)->limit(5), $out, $r200],
];
- \Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->v($this->articles['db'])));
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(42), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("idMissing"));
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(2112), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("idMissing"));
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(-1), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("typeViolation"));
- \Phake::when(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(-1), $this->anything(), ["edition desc"])->thenThrow(new ExceptionInput("typeViolation"));
- $exp = new Response(['items' => $this->articles['rest']]);
- // check the contents of the response
- $this->assertMessage($exp, $this->req("GET", "/items")); // first instance of base context
- $this->assertMessage($exp, $this->req("GET", "/items/updated")); // second instance of base context
- // check error conditions
- $exp = new EmptyResponse(422);
- $this->assertMessage($exp, $this->req("GET", "/items", json_encode($in[0])));
- $this->assertMessage($exp, $this->req("GET", "/items", json_encode($in[1])));
- $this->assertMessage($exp, $this->req("GET", "/items", json_encode($in[2])));
- $this->assertMessage($exp, $this->req("GET", "/items", json_encode($in[3])));
- // simply run through the remainder of the input for later method verification
- $this->req("GET", "/items", json_encode($in[4]));
- $this->req("GET", "/items", json_encode($in[5])); // third instance of base context
- $this->req("GET", "/items", json_encode($in[6]));
- $this->req("GET", "/items", json_encode($in[7]));
- $this->req("GET", "/items", json_encode($in[8])); // fourth instance of base context
- $this->req("GET", "/items", json_encode($in[9]));
- $this->req("GET", "/items", json_encode($in[10]));
- $this->req("GET", "/items", json_encode($in[11]));
- // perform method verifications
- \Phake::verify(Arsse::$db, \Phake::times(4))->articleList(Arsse::$user->id, new Context, $this->anything(), ["edition desc"]);
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(42), $this->anything(), ["edition desc"]);
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(2112), $this->anything(), ["edition desc"]);
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->subscription(-1), $this->anything(), ["edition desc"]);
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->folder(-1), $this->anything(), ["edition desc"]);
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true), $this->anything(), ["edition desc"]);
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(10)->oldestEdition(6), $this->anything(), ["edition"]); // offset is one more than specified
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(5)->latestEdition(4), $this->anything(), ["edition desc"]); // offset is one less than specified
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->unread(true), $this->anything(), ["edition desc"]);
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, $this->equalTo((new Context)->markedSince($t), 2), $this->anything(), ["edition desc"]);
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->limit(5), $this->anything(), ["edition desc"]);
}
public function testMarkAFolderRead(): void {
$read = ['read' => true];
$in = json_encode(['newestItemId' => 2112]);
- \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
+ $this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->folder(1)->latestEdition(2112)->hidden(false)))->returns(42);
+ $this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->folder(42)->latestEdition(2112)->hidden(false)))->throws(new ExceptionInput("idMissing")); // folder doesn't exist
$exp = new EmptyResponse(204);
$this->assertMessage($exp, $this->req("PUT", "/folders/1/read", $in));
$this->assertMessage($exp, $this->req("PUT", "/folders/1/read?newestItemId=2112"));
@@ -722,8 +733,8 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
public function testMarkASubscriptionRead(): void {
$read = ['read' => true];
$in = json_encode(['newestItemId' => 2112]);
- \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
+ $this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->subscription(1)->latestEdition(2112)->hidden(false)))->returns(42);
+ $this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->subscription(42)->latestEdition(2112)->hidden(false)))->throws(new ExceptionInput("idMissing")); // subscription doesn't exist
$exp = new EmptyResponse(204);
$this->assertMessage($exp, $this->req("PUT", "/feeds/1/read", $in));
$this->assertMessage($exp, $this->req("PUT", "/feeds/1/read?newestItemId=2112"));
@@ -737,7 +748,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
public function testMarkAllItemsRead(): void {
$read = ['read' => true];
$in = json_encode(['newestItemId' => 2112]);
- \Phake::when(Arsse::$db)->articleMark(Arsse::$user->id, $read, (new Context)->latestEdition(2112))->thenReturn(42);
+ $this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->latestEdition(2112)))->returns(42);
$exp = new EmptyResponse(204);
$this->assertMessage($exp, $this->req("PUT", "/items/read", $in));
$this->assertMessage($exp, $this->req("PUT", "/items/read?newestItemId=2112"));
@@ -751,14 +762,14 @@ class TestV1_2 extends \JKingWeb\Arsse\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(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(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(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(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
+ $this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->edition(1)))->returns(42);
+ $this->dbMock->articleMark->with($this->userId, $read, $this->equalTo((new Context)->edition(42)))->throws(new ExceptionInput("subjectMissing")); // edition doesn't exist doesn't exist
+ $this->dbMock->articleMark->with($this->userId, $unread, $this->equalTo((new Context)->edition(2)))->returns(42);
+ $this->dbMock->articleMark->with($this->userId, $unread, $this->equalTo((new Context)->edition(47)))->throws(new ExceptionInput("subjectMissing")); // edition doesn't exist doesn't exist
+ $this->dbMock->articleMark->with($this->userId, $star, $this->equalTo((new Context)->article(3)))->returns(42);
+ $this->dbMock->articleMark->with($this->userId, $star, $this->equalTo((new Context)->article(2112)))->throws(new ExceptionInput("subjectMissing")); // article doesn't exist doesn't exist
+ $this->dbMock->articleMark->with($this->userId, $unstar, $this->equalTo((new Context)->article(4)))->returns(42);
+ $this->dbMock->articleMark->with($this->userId, $unstar, $this->equalTo((new Context)->article(1337)))->throws(new ExceptionInput("subjectMissing")); // article doesn't exist doesn't exist
$exp = new EmptyResponse(204);
$this->assertMessage($exp, $this->req("PUT", "/items/1/read"));
$this->assertMessage($exp, $this->req("PUT", "/items/2/unread"));
@@ -769,7 +780,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertMessage($exp, $this->req("PUT", "/items/47/unread"));
$this->assertMessage($exp, $this->req("PUT", "/items/1/2112/star"));
$this->assertMessage($exp, $this->req("PUT", "/items/4400/1337/unstar"));
- \Phake::verify(Arsse::$db, \Phake::times(8))->articleMark(Arsse::$user->id, $this->anything(), $this->anything());
+ $this->dbMock->articleMark->times(8)->calledWith($this->userId, $this->anything(), $this->anything());
}
public function testChangeMarksOfMultipleArticles(): void {
@@ -787,9 +798,9 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
$inStar[$a][$b] = ['feedId' => 2112, 'guidHash' => $inStar[$a][$b]];
}
}
- \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)->articles([]))->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples
+ $this->dbMock->articleMark->with($this->userId, $this->anything(), $this->anything())->returns(42);
+ $this->dbMock->articleMark->with($this->userId, $this->anything(), $this->equalTo((new Context)->editions([])))->throws(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples
+ $this->dbMock->articleMark->with($this->userId, $this->anything(), $this->equalTo((new Context)->articles([])))->throws(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples
$exp = new EmptyResponse(204);
$this->assertMessage($exp, $this->req("PUT", "/items/read/multiple"));
$this->assertMessage($exp, $this->req("PUT", "/items/unread/multiple"));
@@ -812,27 +823,27 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertMessage($exp, $this->req("PUT", "/items/star/multiple", json_encode(['items' => $inStar[1]])));
$this->assertMessage($exp, $this->req("PUT", "/items/unstar/multiple", json_encode(['items' => $inStar[1]])));
// ensure the data model was queried appropriately for read/unread
- \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]));
+ $this->dbMock->articleMark->atLeast(1)->calledWith($this->userId, $read, $this->equalTo((new Context)->editions([])));
+ $this->dbMock->articleMark->atLeast(1)->calledWith($this->userId, $read, $this->equalTo((new Context)->editions($in[0])));
+ $this->dbMock->articleMark->atLeast(1)->calledWith($this->userId, $read, $this->equalTo((new Context)->editions($in[1])));
+ $this->dbMock->articleMark->atLeast(1)->calledWith($this->userId, $unread, $this->equalTo((new Context)->editions([])));
+ $this->dbMock->articleMark->atLeast(1)->calledWith($this->userId, $unread, $this->equalTo((new Context)->editions($in[0])));
+ $this->dbMock->articleMark->atLeast(1)->calledWith($this->userId, $unread, $this->equalTo((new Context)->editions($in[1])));
// ensure the data model was queried appropriately for star/unstar
- \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]));
+ $this->dbMock->articleMark->atLeast(1)->calledWith($this->userId, $star, $this->equalTo((new Context)->articles([])));
+ $this->dbMock->articleMark->atLeast(1)->calledWith($this->userId, $star, $this->equalTo((new Context)->articles($in[0])));
+ $this->dbMock->articleMark->atLeast(1)->calledWith($this->userId, $star, $this->equalTo((new Context)->articles($in[1])));
+ $this->dbMock->articleMark->atLeast(1)->calledWith($this->userId, $unstar, $this->equalTo((new Context)->articles([])));
+ $this->dbMock->articleMark->atLeast(1)->calledWith($this->userId, $unstar, $this->equalTo((new Context)->articles($in[0])));
+ $this->dbMock->articleMark->atLeast(1)->calledWith($this->userId, $unstar, $this->equalTo((new Context)->articles($in[1])));
}
public function testQueryTheServerStatus(): void {
$interval = Arsse::$conf->serviceFrequency;
$valid = (new \DateTimeImmutable("now", new \DateTimezone("UTC")))->sub($interval);
$invalid = $valid->sub($interval)->sub($interval);
- \Phake::when(Arsse::$db)->metaGet("service_last_checkin")->thenReturn(Date::transform($valid, "sql"))->thenReturn(Date::transform($invalid, "sql"));
- \Phake::when(Arsse::$db)->driverCharsetAcceptable->thenReturn(true)->thenReturn(false);
+ $this->dbMock->metaGet->with("service_last_checkin")->returns(Date::transform($valid, "sql"))->returns(Date::transform($invalid, "sql"));
+ $this->dbMock->driverCharsetAcceptable->returns(true)->returns(false);
$arr1 = $arr2 = [
'version' => V1_2::VERSION,
'arsse_version' => Arsse::VERSION,
@@ -848,24 +859,38 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testCleanUpBeforeUpdate(): void {
- \Phake::when(Arsse::$db)->feedCleanup()->thenReturn(true);
+ $this->dbMock->feedCleanup->with()->returns(true);
$exp = new EmptyResponse(204);
$this->assertMessage($exp, $this->req("GET", "/cleanup/before-update"));
- \Phake::verify(Arsse::$db)->feedCleanup();
+ $this->dbMock->feedCleanup->calledWith();
+ }
+
+ public function testCleanUpBeforeUpdateWithoutAuthority(): void {
+ $this->userMock->propertiesGet->returns(['admin' => false]);
+ $exp = new EmptyResponse(403);
+ $this->assertMessage($exp, $this->req("GET", "/cleanup/before-update"));
+ $this->dbMock->feedCleanup->never()->called();
}
public function testCleanUpAfterUpdate(): void {
- \Phake::when(Arsse::$db)->articleCleanup()->thenReturn(true);
+ $this->dbMock->articleCleanup->with()->returns(true);
$exp = new EmptyResponse(204);
$this->assertMessage($exp, $this->req("GET", "/cleanup/after-update"));
- \Phake::verify(Arsse::$db)->articleCleanup();
+ $this->dbMock->articleCleanup->calledWith();
+ }
+
+ public function testCleanUpAfterUpdateWithoutAuthority(): void {
+ $this->userMock->propertiesGet->returns(['admin' => false]);
+ $exp = new EmptyResponse(403);
+ $this->assertMessage($exp, $this->req("GET", "/cleanup/after-update"));
+ $this->dbMock->feedCleanup->never()->called();
}
public function testQueryTheUserStatus(): void {
$act = $this->req("GET", "/user");
$exp = new Response([
- 'userId' => Arsse::$user->id,
- 'displayName' => Arsse::$user->id,
+ 'userId' => $this->userId,
+ 'displayName' => $this->userId,
'lastLoginTimestamp' => $this->approximateTime($act->getPayload()['lastLoginTimestamp'], new \DateTimeImmutable),
'avatar' => null,
]);
@@ -877,10 +902,10 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
$url = "/folders?name=Hardware";
$out1 = ['id' => 1, 'name' => "Software"];
$out2 = ['id' => 2, 'name' => "Hardware"];
- \Phake::when(Arsse::$db)->folderAdd($this->anything(), $this->anything())->thenReturn(2);
- \Phake::when(Arsse::$db)->folderAdd($this->anything(), $in)->thenReturn(1);
- \Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 1)->thenReturn($this->v($out1));
- \Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 2)->thenReturn($this->v($out2));
+ $this->dbMock->folderAdd->with($this->anything(), $this->anything())->returns(2);
+ $this->dbMock->folderAdd->with($this->anything(), $in)->returns(1);
+ $this->dbMock->folderPropertiesGet->with($this->userId, 1)->returns($this->v($out1));
+ $this->dbMock->folderPropertiesGet->with($this->userId, 2)->returns($this->v($out2));
$exp = new Response(['folders' => [$out1]]);
$this->assertMessage($exp, $this->req("POST", "/folders?name=Hardware", json_encode($in)));
}
@@ -888,8 +913,8 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest {
public function testMeldJsonAndQueryParameters(): void {
$in = ['oldestFirst' => true];
$url = "/items?type=2";
- \Phake::when(Arsse::$db)->articleList->thenReturn(new Result([]));
+ $this->dbMock->articleList->returns(new Result([]));
$this->req("GET", $url, json_encode($in));
- \Phake::verify(Arsse::$db)->articleList(Arsse::$user->id, (new Context)->starred(true), $this->anything(), ["edition"]);
+ $this->dbMock->articleList->calledWith($this->userId, $this->equalTo((new Context)->starred(true)->hidden(false)), $this->anything(), ["edition"]);
}
}
diff --git a/tests/cases/REST/NextcloudNews/TestVersions.php b/tests/cases/REST/NextcloudNews/TestVersions.php
index aadb88d5..b5d5679d 100644
--- a/tests/cases/REST/NextcloudNews/TestVersions.php
+++ b/tests/cases/REST/NextcloudNews/TestVersions.php
@@ -14,7 +14,7 @@ use Laminas\Diactoros\Response\EmptyResponse;
/** @covers \JKingWeb\Arsse\REST\NextcloudNews\Versions */
class TestVersions extends \JKingWeb\Arsse\Test\AbstractTest {
public function setUp(): void {
- self::clearData();
+ parent::setUp();
}
protected function req(string $method, string $target): ResponseInterface {
diff --git a/tests/cases/REST/TestREST.php b/tests/cases/REST/TestREST.php
index 151d41b4..15f3aa09 100644
--- a/tests/cases/REST/TestREST.php
+++ b/tests/cases/REST/TestREST.php
@@ -9,7 +9,6 @@ namespace JKingWeb\Arsse\TestCase\REST;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\REST;
-use JKingWeb\Arsse\REST\Handler;
use JKingWeb\Arsse\REST\Exception501;
use JKingWeb\Arsse\REST\NextcloudNews\V1_2 as NCN;
use JKingWeb\Arsse\REST\TinyTinyRSS\API as TTRSS;
@@ -64,11 +63,12 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest {
public function testAuthenticateRequests(array $serverParams, array $expAttr): void {
$r = new REST();
// create a mock user manager
- Arsse::$user = \Phake::mock(User::class);
- \Phake::when(Arsse::$user)->auth->thenReturn(false);
- \Phake::when(Arsse::$user)->auth("john.doe@example.com", "secret")->thenReturn(true);
- \Phake::when(Arsse::$user)->auth("john.doe@example.com", "")->thenReturn(true);
- \Phake::when(Arsse::$user)->auth("someone.else@example.com", "")->thenReturn(true);
+ $this->userMock = $this->mock(User::class);
+ $this->userMock->auth->returns(false);
+ $this->userMock->auth->with("john.doe@example.com", "secret")->returns(true);
+ $this->userMock->auth->with("john.doe@example.com", "")->returns(true);
+ $this->userMock->auth->with("someone.else@example.com", "")->returns(true);
+ Arsse::$user = $this->userMock->get();
// create an input server request
$req = new ServerRequest($serverParams);
// create the expected output
@@ -158,13 +158,13 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideCorsNegotiations */
public function testNegotiateCors($origin, bool $exp, string $allowed = null, string $denied = null): void {
self::setConf();
- $r = \Phake::partialMock(REST::class);
- \Phake::when($r)->corsNormalizeOrigin->thenReturnCallback(function($origin) {
+ $rMock = $this->partialMock(REST::class);
+ $rMock->corsNormalizeOrigin->does(function($origin) {
return $origin;
});
$headers = isset($origin) ? ['Origin' => $origin] : [];
$req = new Request("", "GET", "php://memory", $headers);
- $act = $r->corsNegotiate($req, $allowed, $denied);
+ $act = $rMock->get()->corsNegotiate($req, $allowed, $denied);
$this->assertSame($exp, $act);
}
@@ -259,15 +259,15 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest {
/** @dataProvider provideUnnormalizedResponses */
public function testNormalizeHttpResponses(ResponseInterface $res, ResponseInterface $exp, RequestInterface $req = null): void {
- $r = \Phake::partialMock(REST::class);
- \Phake::when($r)->corsNegotiate->thenReturn(true);
- \Phake::when($r)->challenge->thenReturnCallback(function($res) {
+ $rMock = $this->partialMock(REST::class);
+ $rMock->corsNegotiate->returns(true);
+ $rMock->challenge->does(function($res) {
return $res->withHeader("WWW-Authenticate", "Fake Value");
});
- \Phake::when($r)->corsApply->thenReturnCallback(function($res) {
+ $rMock->corsApply->does(function($res) {
return $res;
});
- $act = $r->normalizeResponse($res, $req);
+ $act = $rMock->get()->normalizeResponse($res, $req);
$this->assertMessage($exp, $act);
}
@@ -293,40 +293,34 @@ class TestREST extends \JKingWeb\Arsse\Test\AbstractTest {
];
}
- public function testCreateHandlers(): void {
- $r = new REST();
- foreach (REST::API_LIST as $api) {
- $class = $api['class'];
- $this->assertInstanceOf(Handler::class, $r->getHandler($class));
- }
- }
-
/** @dataProvider provideMockRequests */
public function testDispatchRequests(ServerRequest $req, string $method, bool $called, string $class = "", string $target = ""): void {
- $r = \Phake::partialMock(REST::class);
- \Phake::when($r)->normalizeResponse->thenReturnCallback(function($res) {
+ $rMock = $this->partialMock(REST::class);
+ $rMock->normalizeResponse->does(function($res) {
return $res;
});
- \Phake::when($r)->authenticateRequest->thenReturnCallback(function($req) {
+ $rMock->authenticateRequest->does(function($req) {
return $req;
});
if ($called) {
- $h = \Phake::mock($class);
- \Phake::when($r)->getHandler($class)->thenReturn($h);
- \Phake::when($h)->dispatch->thenReturn(new EmptyResponse(204));
+ $hMock = $this->mock($class);
+ $hMock->dispatch->returns(new EmptyResponse(204));
+ $this->objMock->get->with($class)->returns($hMock);
+ Arsse::$obj = $this->objMock->get();
}
- $out = $r->dispatch($req);
+ $out = $rMock->get()->dispatch($req);
$this->assertInstanceOf(ResponseInterface::class, $out);
if ($called) {
- \Phake::verify($r)->authenticateRequest;
- \Phake::verify($h)->dispatch(\Phake::capture($in));
+ $rMock->authenticateRequest->called();
+ $hMock->dispatch->once()->called();
+ $in = $hMock->dispatch->firstCall()->argument();
$this->assertSame($method, $in->getMethod());
$this->assertSame($target, $in->getRequestTarget());
} else {
$this->assertSame(501, $out->getStatusCode());
}
- \Phake::verify($r)->apiMatch;
- \Phake::verify($r)->normalizeResponse;
+ $rMock->apiMatch->called();
+ $rMock->normalizeResponse->called();
}
public function provideMockRequests(): iterable {
diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php
index d5ca279f..74a12b95 100644
--- a/tests/cases/REST/TinyTinyRSS/TestAPI.php
+++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php
@@ -6,7 +6,6 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\REST\TinyTinyRSS;
-use GuzzleHttp\Exception\ClientException;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Database;
@@ -16,6 +15,7 @@ use JKingWeb\Arsse\Context\Context;
use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Db\Transaction;
use JKingWeb\Arsse\REST\TinyTinyRSS\API;
+use JKingWeb\Arsse\Feed\Exception as FeedException;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\JsonResponse as Response;
use Laminas\Diactoros\Response\EmptyResponse;
@@ -23,7 +23,10 @@ use Laminas\Diactoros\Response\EmptyResponse;
/** @covers \JKingWeb\Arsse\REST\TinyTinyRSS\API
* @covers \JKingWeb\Arsse\REST\TinyTinyRSS\Exception */
class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
+ protected const NOW = "2020-12-21T23:09:17.189065Z";
+
protected $h;
+ protected $userId = "john.doe@example.com";
protected $folders = [
['id' => 5, 'parent' => 3, 'children' => 0, 'feeds' => 1, 'name' => "Local"],
['id' => 6, 'parent' => 3, 'children' => 0, 'feeds' => 2, 'name' => "National"],
@@ -38,12 +41,12 @@ class TestAPI extends \JKingWeb\Arsse\Test\AbstractTest {
['id' => 1, 'parent' => null, 'children' => 1, 'feeds' => 1, 'name' => "Science"],
];
protected $subscriptions = [
- ['id' => 3, 'folder' => 1, 'top_folder' => 1, 'unread' => 2, 'updated' => "2016-05-23 06:40:02", 'err_msg' => 'argh', 'title' => 'Ars Technica', 'url' => " http://example.com/3", 'favicon' => 'http://example.com/3.png'],
- ['id' => 4, 'folder' => 6, 'top_folder' => 3, 'unread' => 6, 'updated' => "2017-10-09 15:58:34", 'err_msg' => '', 'title' => 'CBC News', 'url' => " http://example.com/4", 'favicon' => 'http://example.com/4.png'],
- ['id' => 6, 'folder' => null, 'top_folder' => null, 'unread' => 0, 'updated' => "2010-02-12 20:08:47", 'err_msg' => '', 'title' => 'Eurogamer', 'url' => " http://example.com/6", 'favicon' => 'http://example.com/6.png'],
- ['id' => 1, 'folder' => 2, 'top_folder' => 1, 'unread' => 5, 'updated' => "2017-09-15 22:54:16", 'err_msg' => '', 'title' => 'NASA JPL', 'url' => " http://example.com/1", 'favicon' => null],
- ['id' => 5, 'folder' => 6, 'top_folder' => 3, 'unread' => 12, 'updated' => "2017-07-07 17:07:17", 'err_msg' => '', 'title' => 'Ottawa Citizen', 'url' => " http://example.com/5", 'favicon' => ''],
- ['id' => 2, 'folder' => 5, 'top_folder' => 3, 'unread' => 10, 'updated' => "2011-11-11 11:11:11", 'err_msg' => 'oops', 'title' => 'Toronto Star', 'url' => " http://example.com/2", 'favicon' => 'http://example.com/2.png'],
+ ['id' => 3, 'folder' => 1, 'top_folder' => 1, 'unread' => 2, 'updated' => "2016-05-23 06:40:02", 'err_msg' => 'argh', 'title' => 'Ars Technica', 'url' => " http://example.com/3", 'icon_url' => 'http://example.com/3.png'],
+ ['id' => 4, 'folder' => 6, 'top_folder' => 3, 'unread' => 6, 'updated' => "2017-10-09 15:58:34", 'err_msg' => '', 'title' => 'CBC News', 'url' => " http://example.com/4", 'icon_url' => 'http://example.com/4.png'],
+ ['id' => 6, 'folder' => null, 'top_folder' => null, 'unread' => 0, 'updated' => "2010-02-12 20:08:47", 'err_msg' => '', 'title' => 'Eurogamer', 'url' => " http://example.com/6", 'icon_url' => 'http://example.com/6.png'],
+ ['id' => 1, 'folder' => 2, 'top_folder' => 1, 'unread' => 5, 'updated' => "2017-09-15 22:54:16", 'err_msg' => '', 'title' => 'NASA JPL', 'url' => " http://example.com/1", 'icon_url' => null],
+ ['id' => 5, 'folder' => 6, 'top_folder' => 3, 'unread' => 12, 'updated' => "2017-07-07 17:07:17", 'err_msg' => '', 'title' => 'Ottawa Citizen', 'url' => " http://example.com/5", 'icon_url' => ''],
+ ['id' => 2, 'folder' => 5, 'top_folder' => 3, 'unread' => 10, 'updated' => "2011-11-11 11:11:11", 'err_msg' => 'oops', 'title' => 'Toronto Star', 'url' => " http://example.com/2", 'icon_url' => 'http://example.com/2.png'],
];
protected $labels = [
['id' => 3, 'articles' => 100, 'read' => 94, 'unread' => 6, 'name' => "Fascinating"],
@@ -125,7 +128,33 @@ LONG_STRING;
return $value;
}
+ public function setUp(): void {
+ parent::setUp();
+ self::setConf();
+ // create mock timestamps
+ $this->objMock->get->with(\DateTimeImmutable::class)->returns(new \DateTimeImmutable(self::NOW));
+ // create a mock user manager
+ $this->userId = "john.doe@example.com";
+ $this->userMock = $this->mock(User::class);
+ $this->userMock->auth->returns(true);
+ // create a mock database interface
+ $this->dbMock = $this->mock(Database::class);
+ $this->dbMock->begin->returns($this->mock(Transaction::class));
+ $this->dbMock->sessionResume->throws(new \JKingWeb\Arsse\User\ExceptionSession("invalid"));
+ $this->dbMock->sessionResume->with("PriestsOfSyrinx")->returns([
+ 'id' => "PriestsOfSyrinx",
+ 'created' => "2000-01-01 00:00:00",
+ 'expires' => "2112-12-21 21:12:00",
+ 'user' => $this->userId,
+ ]);
+ $this->h = new API();
+ }
+
protected function req($data, string $method = "POST", string $target = "", string $strData = null, string $user = null): ResponseInterface {
+ Arsse::$obj = $this->objMock->get();
+ Arsse::$db = $this->dbMock->get();
+ Arsse::$user = $this->userMock->get();
+ Arsse::$user->id = $this->userId;
$prefix = "/tt-rss/api";
$url = $prefix.$target;
$body = $strData ?? json_encode($data);
@@ -154,30 +183,6 @@ LONG_STRING;
]);
}
- public function setUp(): void {
- self::clearData();
- self::setConf();
- // create a mock user manager
- Arsse::$user = \Phake::mock(User::class);
- \Phake::when(Arsse::$user)->auth->thenReturn(true);
- Arsse::$user->id = "john.doe@example.com";
- // create a mock database interface
- Arsse::$db = \Phake::mock(Database::class);
- \Phake::when(Arsse::$db)->begin->thenReturn(\Phake::mock(Transaction::class));
- \Phake::when(Arsse::$db)->sessionResume->thenThrow(new \JKingWeb\Arsse\User\ExceptionSession("invalid"));
- \Phake::when(Arsse::$db)->sessionResume("PriestsOfSyrinx")->thenReturn([
- 'id' => "PriestsOfSyrinx",
- 'created' => "2000-01-01 00:00:00",
- 'expires' => "2112-12-21 21:12:00",
- 'user' => Arsse::$user->id,
- ]);
- $this->h = new API();
- }
-
- public function tearDown(): void {
- self::clearData();
- }
-
public function testHandleInvalidPaths(): void {
$exp = $this->respErr("MALFORMED_INPUT", [], null);
$this->assertMessage($exp, $this->req(null, "POST", "", ""));
@@ -203,13 +208,13 @@ LONG_STRING;
/** @dataProvider provideLoginRequests */
public function testLogIn(array $conf, $httpUser, array $data, $sessions): void {
- Arsse::$user->id = null;
+ $this->userId = null;
self::setConf($conf);
- \Phake::when(Arsse::$user)->auth->thenReturn(false);
- \Phake::when(Arsse::$user)->auth("john.doe@example.com", "secret")->thenReturn(true);
- \Phake::when(Arsse::$user)->auth("jane.doe@example.com", "superman")->thenReturn(true);
- \Phake::when(Arsse::$db)->sessionCreate("john.doe@example.com")->thenReturn("PriestsOfSyrinx")->thenReturn("SolarFederation");
- \Phake::when(Arsse::$db)->sessionCreate("jane.doe@example.com")->thenReturn("ClockworkAngels")->thenReturn("SevenCitiesOfGold");
+ $this->userMock->auth->returns(false);
+ $this->userMock->auth->with("john.doe@example.com", "secret")->returns(true);
+ $this->userMock->auth->with("jane.doe@example.com", "superman")->returns(true);
+ $this->dbMock->sessionCreate->with("john.doe@example.com")->returns("PriestsOfSyrinx", "SolarFederation");
+ $this->dbMock->sessionCreate->with("jane.doe@example.com")->returns("ClockworkAngels", "SevenCitiesOfGold");
if ($sessions instanceof EmptyResponse) {
$exp1 = $sessions;
$exp2 = $sessions;
@@ -228,7 +233,7 @@ LONG_STRING;
}
$this->assertMessage($exp2, $this->reqAuth($data, $httpUser));
// logging in should never try to resume a session
- \Phake::verify(Arsse::$db, \Phake::times(0))->sessionResume($this->anything());
+ $this->dbMock->sessionResume->never()->called();
}
public function provideLoginRequests(): iterable {
@@ -237,15 +242,15 @@ LONG_STRING;
/** @dataProvider provideResumeRequests */
public function testValidateASession(array $conf, $httpUser, string $data, $result): void {
- Arsse::$user->id = null;
+ $this->userId = null;
self::setConf($conf);
- \Phake::when(Arsse::$db)->sessionResume("PriestsOfSyrinx")->thenReturn([
+ $this->dbMock->sessionResume->with("PriestsOfSyrinx")->returns([
'id' => "PriestsOfSyrinx",
'created' => "2000-01-01 00:00:00",
'expires' => "2112-12-21 21:12:00",
'user' => "john.doe@example.com",
]);
- \Phake::when(Arsse::$db)->sessionResume("ClockworkAngels")->thenReturn([
+ $this->dbMock->sessionResume->with("ClockworkAngels")->returns([
'id' => "ClockworkAngels",
'created' => "2000-01-01 00:00:00",
'expires' => "2112-12-21 21:12:00",
@@ -521,10 +526,10 @@ LONG_STRING;
}
public function testHandleGenericError(): void {
- \Phake::when(Arsse::$user)->auth(Arsse::$user->id, $this->anything())->thenThrow(new \JKingWeb\Arsse\Db\ExceptionTimeout("general"));
+ $this->userMock->auth->throws(new \JKingWeb\Arsse\Db\ExceptionTimeout("general"));
$data = [
'op' => "login",
- 'user' => Arsse::$user->id,
+ 'user' => $this->userId,
'password' => "secret",
];
$exp = new EmptyResponse(500);
@@ -532,14 +537,14 @@ LONG_STRING;
}
public function testLogOut(): void {
- \Phake::when(Arsse::$db)->sessionDestroy->thenReturn(true);
+ $this->dbMock->sessionDestroy->returns(true);
$data = [
'op' => "logout",
'sid' => "PriestsOfSyrinx",
];
$exp = $this->respGood(['status' => "OK"]);
$this->assertMessage($exp, $this->req($data));
- \Phake::verify(Arsse::$db)->sessionDestroy(Arsse::$user->id, "PriestsOfSyrinx");
+ $this->dbMock->sessionDestroy->calledWith($this->userId, "PriestsOfSyrinx");
}
public function testHandleUnknownMethods(): void {
@@ -587,356 +592,236 @@ LONG_STRING;
$this->assertMessage($exp, $this->req($data));
}
- public function testAddACategory(): void {
- $in = [
- ['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => "Software"],
- ['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => "Hardware", 'parent_id' => 1],
- ['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => "Hardware", 'parent_id' => 2112],
- ['op' => "addCategory", 'sid' => "PriestsOfSyrinx"],
- ['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => ""],
- ['op' => "addCategory", 'sid' => "PriestsOfSyrinx", 'caption' => " "],
- ];
- $db = [
- ['name' => "Software", 'parent' => null],
- ['name' => "Hardware", 'parent' => 1],
- ['name' => "Hardware", 'parent' => 2112],
- ];
- $out = [
+ /** @dataProvider provideCategoryAdditions */
+ public function testAddACategory(array $in, array $data, $out, ResponseInterface $exp): void {
+ $in = array_merge(['op' => "addCategory", 'sid' => "PriestsOfSyrinx"], $in);
+ $action = ($out instanceof \Exception) ? "throws" : "returns";
+ $this->dbMock->folderAdd->$action($out);
+ $this->dbMock->folderList->with("~", null, false)->returns(new Result($this->v([
['id' => 2, 'name' => "Software", 'parent' => null],
- ['id' => 3, 'name' => "Hardware", 'parent' => 1],
['id' => 1, 'name' => "Politics", 'parent' => null],
- ];
- // set of various mocks for testing
- \Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, $db[0])->thenReturn(2)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call
- \Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, $db[1])->thenReturn(3)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call
- \Phake::when(Arsse::$db)->folderList(Arsse::$user->id, null, false)->thenReturn(new Result($this->v([$out[0], $out[2]])));
- \Phake::when(Arsse::$db)->folderList(Arsse::$user->id, 1, false)->thenReturn(new Result($this->v([$out[1]])));
- // set up mocks that produce errors
- \Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, $db[2])->thenThrow(new ExceptionInput("idMissing")); // parent folder does not exist
- \Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, [])->thenThrow(new ExceptionInput("missing"));
- \Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => "", 'parent' => null])->thenThrow(new ExceptionInput("missing"));
- \Phake::when(Arsse::$db)->folderAdd(Arsse::$user->id, ['name' => " ", 'parent' => null])->thenThrow(new ExceptionInput("whitespace"));
- // correctly add two folders
- $exp = $this->respGood("2");
- $this->assertMessage($exp, $this->req($in[0]));
- $exp = $this->respGood("3");
- $this->assertMessage($exp, $this->req($in[1]));
- // attempt to add the two folders again
- $exp = $this->respGood("2");
- $this->assertMessage($exp, $this->req($in[0]));
- $exp = $this->respGood("3");
- $this->assertMessage($exp, $this->req($in[1]));
- \Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, null, false);
- \Phake::verify(Arsse::$db)->folderList(Arsse::$user->id, 1, false);
- // add a folder to a missing parent (silently fails)
- $exp = $this->respGood(false);
- $this->assertMessage($exp, $this->req($in[2]));
- // add some invalid folders
- $exp = $this->respErr("INCORRECT_USAGE");
- $this->assertMessage($exp, $this->req($in[3]));
- $this->assertMessage($exp, $this->req($in[4]));
- $this->assertMessage($exp, $this->req($in[5]));
+ ])));
+ $this->dbMock->folderList->with("~", 1, false)->returns(new Result($this->v([
+ ['id' => 3, 'name' => "Hardware", 'parent' => 1],
+ ])));
+ $this->assertMessage($exp, $this->req($in));
+ $this->dbMock->folderAdd->calledWith($this->userId, $data);
+ if (!$out instanceof \Exception) {
+ $this->dbMock->folderList->never()->called();
+ }
}
- public function testRemoveACategory(): void {
- $in = [
- ['op' => "removeCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42],
- ['op' => "removeCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 2112],
- ['op' => "removeCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => -1],
+ public function provideCategoryAdditions(): iterable {
+ return [
+ [[], ['name' => null, 'parent' => null], new ExceptionInput("missing"), $this->respErr("INCORRECT_USAGE")],
+ [['caption' => ""], ['name' => "", 'parent' => null], new ExceptionInput("missing"), $this->respErr("INCORRECT_USAGE")],
+ [['caption' => " "], ['name' => " ", 'parent' => null], new ExceptionInput("whitespace"), $this->respErr("INCORRECT_USAGE")],
+ [['caption' => "Software"], ['name' => "Software", 'parent' => null], 2, $this->respGood("2")],
+ [['caption' => "Hardware", 'parent_id' => 1], ['name' => "Hardware", 'parent' => 1], 3, $this->respGood("3")],
+ [['caption' => "Hardware", 'parent_id' => 2112], ['name' => "Hardware", 'parent' => 2112], new ExceptionInput("idMissing"), $this->respGood(false)],
+ [['caption' => "Software"], ['name' => "Software", 'parent' => null], new ExceptionInput("constraintViolation"), $this->respGood("2")],
+ [['caption' => "Hardware", 'parent_id' => 1], ['name' => "Hardware", 'parent' => 1], new ExceptionInput("constraintViolation"), $this->respGood("3")],
];
- \Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, $this->anything())->thenThrow(new ExceptionInput("subjectMissing"));
- \Phake::when(Arsse::$db)->folderRemove(Arsse::$user->id, 42)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing"));
- // succefully delete a folder
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[0]));
- // try deleting it again (this should silently fail)
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[0]));
- // delete a folder which does not exist (this should also silently fail)
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[1]));
- // delete an invalid folder (causes an error)
- $exp = $this->respErr("INCORRECT_USAGE");
- $this->assertMessage($exp, $this->req($in[2]));
- \Phake::verify(Arsse::$db, \Phake::times(3))->folderRemove(Arsse::$user->id, $this->anything());
}
- public function testMoveACategory(): void {
- $in = [
- ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'parent_id' => 1],
- ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 2112, 'parent_id' => 2],
- ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'parent_id' => 0],
- ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'parent_id' => 47],
- ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => -1, 'parent_id' => 1],
- ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'parent_id' => -1],
- ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42],
- ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx", 'parent_id' => -1],
- ['op' => "moveCategory", 'sid' => "PriestsOfSyrinx"],
- ];
- $db = [
- [Arsse::$user->id, 42, ['parent' => 1]],
- [Arsse::$user->id, 2112, ['parent' => 2]],
- [Arsse::$user->id, 42, ['parent' => 0]],
- [Arsse::$user->id, 42, ['parent' => 47]],
- [Arsse::$user->id, -1, ['parent' => 1]],
- [Arsse::$user->id, 42, ['parent' => -1]],
- [Arsse::$user->id, 42, ['parent' => 0]],
- [Arsse::$user->id, 0, ['parent' => -1]],
- [Arsse::$user->id, 0, ['parent' => 0]],
- ];
- \Phake::when(Arsse::$db)->folderPropertiesSet(...$db[0])->thenReturn(true);
- \Phake::when(Arsse::$db)->folderPropertiesSet(...$db[1])->thenThrow(new ExceptionInput("subjectMissing"));
- \Phake::when(Arsse::$db)->folderPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation"));
- \Phake::when(Arsse::$db)->folderPropertiesSet(...$db[3])->thenThrow(new ExceptionInput("idMissing"));
- \Phake::when(Arsse::$db)->folderPropertiesSet(...$db[4])->thenThrow(new ExceptionInput("typeViolation"));
- \Phake::when(Arsse::$db)->folderPropertiesSet(...$db[5])->thenThrow(new ExceptionInput("typeViolation"));
- \Phake::when(Arsse::$db)->folderPropertiesSet(...$db[6])->thenThrow(new ExceptionInput("constraintViolation"));
- \Phake::when(Arsse::$db)->folderPropertiesSet(...$db[7])->thenThrow(new ExceptionInput("typeViolation"));
- \Phake::when(Arsse::$db)->folderPropertiesSet(...$db[8])->thenThrow(new ExceptionInput("typeViolation"));
- // succefully move a folder
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[0]));
- // move a folder which does not exist (this should silently fail)
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[1]));
- // move a folder causing a duplication (this should also silently fail)
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[2]));
- $this->assertMessage($exp, $this->req($in[3]));
- $this->assertMessage($exp, $this->req($in[6]));
- // all the rest should cause errors
- $exp = $this->respErr("INCORRECT_USAGE");
- $this->assertMessage($exp, $this->req($in[4]));
- $this->assertMessage($exp, $this->req($in[5]));
- $this->assertMessage($exp, $this->req($in[7]));
- $this->assertMessage($exp, $this->req($in[8]));
- \Phake::verify(Arsse::$db, \Phake::times(5))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything());
+ /** @dataProvider provideCategoryRemovals */
+ public function testRemoveACategory(array $in, ?int $data, $out, ResponseInterface $exp): void {
+ $in = array_merge(['op' => "removeCategory", 'sid' => "PriestsOfSyrinx"], $in);
+ $action = ($out instanceof \Exception) ? "throws" : "returns";
+ $this->dbMock->folderRemove->$action($out);
+ $this->assertMessage($exp, $this->req($in));
+ if ($data > 0) {
+ $this->dbMock->folderRemove->calledWith($this->userId, (int) $data);
+ }
}
- public function testRenameACategory(): void {
- $in = [
- ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'caption' => "Ook"],
- ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 2112, 'caption' => "Eek"],
- ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'caption' => "Eek"],
- ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'caption' => ""],
- ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42, 'caption' => " "],
- ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => -1, 'caption' => "Ook"],
- ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'category_id' => 42],
- ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx", 'caption' => "Ook"],
- ['op' => "renameCategory", 'sid' => "PriestsOfSyrinx"],
+ public function provideCategoryRemovals(): iterable {
+ return [
+ [['category_id' => 42], 42, true, $this->respGood()],
+ [['category_id' => 2112], 2112, new ExceptionInput("subjectMissing"), $this->respGood()],
+ [[], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['category_id' => -1], null, null, $this->respErr("INCORRECT_USAGE")],
];
- $db = [
- [Arsse::$user->id, 42, ['name' => "Ook"]],
- [Arsse::$user->id, 2112, ['name' => "Eek"]],
- [Arsse::$user->id, 42, ['name' => "Eek"]],
- ];
- \Phake::when(Arsse::$db)->folderPropertiesSet(...$db[0])->thenReturn(true);
- \Phake::when(Arsse::$db)->folderPropertiesSet(...$db[1])->thenThrow(new ExceptionInput("subjectMissing"));
- \Phake::when(Arsse::$db)->folderPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation"));
- // succefully rename a folder
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[0]));
- // rename a folder which does not exist (this should silently fail)
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[1]));
- // rename a folder causing a duplication (this should also silently fail)
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[2]));
- // all the rest should cause errors
- $exp = $this->respErr("INCORRECT_USAGE");
- $this->assertMessage($exp, $this->req($in[3]));
- $this->assertMessage($exp, $this->req($in[4]));
- $this->assertMessage($exp, $this->req($in[5]));
- $this->assertMessage($exp, $this->req($in[6]));
- $this->assertMessage($exp, $this->req($in[7]));
- $this->assertMessage($exp, $this->req($in[8]));
- \Phake::verify(Arsse::$db, \Phake::times(3))->folderPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything());
}
- public function testAddASubscription(): void {
- $in = [
- ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/0"],
- ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/1", 'category_id' => 42],
- ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/2", 'category_id' => 2112],
- ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/3"],
- ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://localhost:8000/Feed/Discovery/Valid"],
- ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://localhost:8000/Feed/Discovery/Invalid"],
- ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/6"],
- ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/7"],
- ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/8", 'category_id' => 47],
- ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/9", 'category_id' => 1],
- // these don't even query the database as the input is syntactically invalid
- ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx"],
- ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/", 'login' => []],
- ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/", 'login' => "", 'password' => []],
- ['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx", 'feed_url' => "http://example.com/", 'category_id' => -1],
+ /** @dataProvider provideCategoryMoves */
+ public function testMoveACategory(array $in, array $data, $out, ResponseInterface $exp): void {
+ $in = array_merge(['op' => "moveCategory", 'sid' => "PriestsOfSyrinx"], $in);
+ $action = ($out instanceof \Exception) ? "throws" : "returns";
+ $this->dbMock->folderPropertiesSet->$action($out);
+ $this->assertMessage($exp, $this->req($in));
+ if ($out !== null) {
+ $this->dbMock->folderPropertiesSet->calledWith(...$data);
+ } else {
+ $this->dbMock->folderPropertiesSet->never()->called();
+ }
+ }
+
+ public function provideCategoryMoves(): iterable {
+ return [
+ [['category_id' => 42, 'parent_id' => 1], [$this->userId, 42, ['parent' => 1]], true, $this->respGood()],
+ [['category_id' => 2112, 'parent_id' => 2], [$this->userId, 2112, ['parent' => 2]], new ExceptionInput("subjectMissing"), $this->respGood()],
+ [['category_id' => 42, 'parent_id' => 0], [$this->userId, 42, ['parent' => 0]], new ExceptionInput("constraintViolation"), $this->respGood()],
+ [['category_id' => 42, 'parent_id' => 47], [$this->userId, 42, ['parent' => 47]], new ExceptionInput("idMissing"), $this->respGood()],
+ [['category_id' => -1, 'parent_id' => 1], [$this->userId, -1, ['parent' => 1]], null, $this->respErr("INCORRECT_USAGE")],
+ [['category_id' => 42, 'parent_id' => -1], [$this->userId, 42, ['parent' => -1]], null, $this->respErr("INCORRECT_USAGE")],
+ [['category_id' => 42], [$this->userId, 42, ['parent' => 0]], new ExceptionInput("constraintViolation"), $this->respGood()],
+ [['parent_id' => -1], [$this->userId, 0, ['parent' => -1]], null, $this->respErr("INCORRECT_USAGE")],
+ [[], [$this->userId, 0, ['parent' => 0]], null, $this->respErr("INCORRECT_USAGE")],
];
- $db = [
- [Arsse::$user->id, "http://example.com/0", "", ""],
- [Arsse::$user->id, "http://example.com/1", "", ""],
- [Arsse::$user->id, "http://example.com/2", "", ""],
- [Arsse::$user->id, "http://example.com/3", "", ""],
- [Arsse::$user->id, "http://localhost:8000/Feed/Discovery/Valid", "", ""],
- [Arsse::$user->id, "http://localhost:8000/Feed/Discovery/Invalid", "", ""],
- [Arsse::$user->id, "http://example.com/6", "", ""],
- [Arsse::$user->id, "http://example.com/7", "", ""],
- [Arsse::$user->id, "http://example.com/8", "", ""],
- [Arsse::$user->id, "http://example.com/9", "", ""],
- ];
- $out = [
- ['code' => 1, 'feed_id' => 2],
- ['code' => 5, 'message' => (new \JKingWeb\Arsse\Feed\Exception("http://example.com/1", $this->mockGuzzleException(ClientException::class, "", 401)))->getMessage()],
- ['code' => 1, 'feed_id' => 0],
- ['code' => 0, 'feed_id' => 3],
- ['code' => 0, 'feed_id' => 1],
- ['code' => 3, 'message' => (new \JKingWeb\Arsse\Feed\Exception("http://localhost:8000/Feed/Discovery/Invalid", new \PicoFeed\Reader\SubscriptionNotFoundException()))->getMessage()],
- ['code' => 2, 'message' => (new \JKingWeb\Arsse\Feed\Exception("http://example.com/6", $this->mockGuzzleException(ClientException::class, "", 404)))->getMessage()],
- ['code' => 6, 'message' => (new \JKingWeb\Arsse\Feed\Exception("http://example.com/7", new \PicoFeed\Parser\MalformedXmlException()))->getMessage()],
- ['code' => 1, 'feed_id' => 4],
- ['code' => 0, 'feed_id' => 4],
+ }
+
+ /** @dataProvider provideCategoryRenamings */
+ public function testRenameACategory(array $in, ?array $data, $out, ResponseInterface $exp): void {
+ $in = array_merge(['op' => "renameCategory", 'sid' => "PriestsOfSyrinx"], $in);
+ $action = ($out instanceof \Exception) ? "throws" : "returns";
+ $this->dbMock->folderPropertiesSet->$action($out);
+ $this->assertMessage($exp, $this->req($in));
+ if ($out !== null) {
+ $this->dbMock->folderPropertiesSet->calledWith(...$data);
+ } else {
+ $this->dbMock->folderPropertiesSet->never()->called();
+ }
+ }
+
+ public function provideCategoryRenamings(): iterable {
+ return [
+ [['category_id' => 42, 'caption' => "Ook"], [$this->userId, 42, ['name' => "Ook"]], true, $this->respGood()],
+ [['category_id' => 2112, 'caption' => "Eek"], [$this->userId, 2112, ['name' => "Eek"]], new ExceptionInput("subjectMissing"), $this->respGood()],
+ [['category_id' => 42, 'caption' => "Eek"], [$this->userId, 42, ['name' => "Eek"]], new ExceptionInput("constraintViolation"), $this->respGood()],
+ [['category_id' => 42, 'caption' => ""], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['category_id' => 42, 'caption' => " "], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['category_id' => -1, 'caption' => "Ook"], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['category_id' => 42], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['caption' => "Ook"], null, null, $this->respErr("INCORRECT_USAGE")],
+ [[], null, null, $this->respErr("INCORRECT_USAGE")],
];
+ }
+
+ /** @dataProvider provideFeedSubscriptions */
+ public function testAddASubscription(array $in, ?array $data, $out, ResponseInterface $exp): void {
+ $in = array_merge(['op' => "subscribeToFeed", 'sid' => "PriestsOfSyrinx"], $in);
+ $action = ($out instanceof \Exception) ? "throws" : "returns";
$list = [
['id' => 1, 'url' => "http://localhost:8000/Feed/Discovery/Feed"],
['id' => 2, 'url' => "http://example.com/0"],
['id' => 3, 'url' => "http://example.com/3"],
['id' => 4, 'url' => "http://example.com/9"],
];
- \Phake::when(Arsse::$db)->subscriptionAdd(...$db[0])->thenReturn(2);
- \Phake::when(Arsse::$db)->subscriptionAdd(...$db[1])->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.com/1", $this->mockGuzzleException(ClientException::class, "", 401)));
- \Phake::when(Arsse::$db)->subscriptionAdd(...$db[2])->thenReturn(2);
- \Phake::when(Arsse::$db)->subscriptionAdd(...$db[3])->thenThrow(new ExceptionInput("constraintViolation"));
- \Phake::when(Arsse::$db)->subscriptionAdd(...$db[4])->thenThrow(new ExceptionInput("constraintViolation"));
- \Phake::when(Arsse::$db)->subscriptionAdd(...$db[5])->thenThrow(new ExceptionInput("constraintViolation"));
- \Phake::when(Arsse::$db)->subscriptionAdd(...$db[6])->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.com/6", $this->mockGuzzleException(ClientException::class, "", 404)));
- \Phake::when(Arsse::$db)->subscriptionAdd(...$db[7])->thenThrow(new \JKingWeb\Arsse\Feed\Exception("http://example.com/7", new \PicoFeed\Parser\MalformedXmlException()));
- \Phake::when(Arsse::$db)->subscriptionAdd(...$db[8])->thenReturn(4);
- \Phake::when(Arsse::$db)->subscriptionAdd(...$db[9])->thenThrow(new ExceptionInput("constraintViolation"));
- \Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 42)->thenReturn($this->v(['id' => 42]));
- \Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 47)->thenReturn($this->v(['id' => 47]));
- \Phake::when(Arsse::$db)->folderPropertiesGet(Arsse::$user->id, 2112)->thenThrow(new ExceptionInput("subjectMissing"));
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything())->thenReturn(true);
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(Arsse::$user->id, 4, $this->anything())->thenThrow(new ExceptionInput("idMissing"));
- \Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result($this->v($list)));
- for ($a = 0; $a < (sizeof($in) - 4); $a++) {
- $exp = $this->respGood($out[$a]);
- $this->assertMessage($exp, $this->req($in[$a]), "Failed test $a");
+ $this->dbMock->subscriptionAdd->$action($out);
+ $this->dbMock->folderPropertiesGet->with($this->userId, 42)->returns($this->v(['id' => 42]));
+ $this->dbMock->folderPropertiesGet->with($this->userId, 47)->returns($this->v(['id' => 47]));
+ $this->dbMock->folderPropertiesGet->with($this->userId, 2112)->throws(new ExceptionInput("subjectMissing"));
+ $this->dbMock->subscriptionPropertiesSet->with($this->userId, "*")->returns(true);
+ $this->dbMock->subscriptionPropertiesSet->with($this->userId, 4, "~")->throws(new ExceptionInput("idMissing"));
+ $this->dbMock->subscriptionList->with($this->userId)->returns(new Result($this->v($list)));
+ $this->assertMessage($exp, $this->req($in));
+ if ($data !== null) {
+ $this->dbMock->subscriptionAdd->calledWith(...$data);
+ } else {
+ $this->dbMock->subscriptionAdd->never()->called();
}
- $exp = $this->respErr("INCORRECT_USAGE");
- for ($a = (sizeof($in) - 4); $a < sizeof($in); $a++) {
- $this->assertMessage($exp, $this->req($in[$a]), "Failed test $a");
+ $this->dbMock->subscriptionPropertiesSet->never()->calledWith($this->userId, 4, ['folder' => 1]);
+ }
+
+ public function provideFeedSubscriptions(): iterable {
+ return [
+ [['feed_url' => "http://example.com/0"], [$this->userId, "http://example.com/0", "", ""], 2, $this->respGood(['code' => 1, 'feed_id' => 2])],
+ [['feed_url' => "http://example.com/1", 'category_id' => 42], [$this->userId, "http://example.com/1", "", ""], new FeedException("unauthorized"), $this->respGood(['code' => 5, 'message' => (new FeedException("unauthorized"))->getMessage()])],
+ [['feed_url' => "http://example.com/2", 'category_id' => 2112], null, null, $this->respGood(['code' => 1, 'feed_id' => 0])],
+ [['feed_url' => "http://example.com/3"], [$this->userId, "http://example.com/3", "", ""], new ExceptionInput("constraintViolation"), $this->respGood(['code' => 0, 'feed_id' => 3])],
+ [['feed_url' => "http://localhost:8000/Feed/Discovery/Valid"], [$this->userId, "http://localhost:8000/Feed/Discovery/Valid", "", ""], new ExceptionInput("constraintViolation"), $this->respGood(['code' => 0, 'feed_id' => 1])],
+ [['feed_url' => "http://localhost:8000/Feed/Discovery/Invalid"], [$this->userId, "http://localhost:8000/Feed/Discovery/Invalid", "", ""], new ExceptionInput("constraintViolation"), $this->respGood(['code' => 3, 'message' => (new FeedException("subscriptionNotFound", ['url' => "http://localhost:8000/Feed/Discovery/Invalid"]))->getMessage()])],
+ [['feed_url' => "http://example.com/6"], [$this->userId, "http://example.com/6", "", ""], new FeedException("invalidUrl"), $this->respGood(['code' => 2, 'message' => (new FeedException("invalidUrl"))->getMessage()])],
+ [['feed_url' => "http://example.com/7"], [$this->userId, "http://example.com/7", "", ""], new FeedException("malformedXml"), $this->respGood(['code' => 6, 'message' => (new FeedException("malformedXml"))->getMessage()])],
+ [['feed_url' => "http://example.com/8", 'category_id' => 47], [$this->userId, "http://example.com/8", "", ""], 4, $this->respGood(['code' => 1, 'feed_id' => 4])],
+ [['feed_url' => "http://example.com/9", 'category_id' => 1], [$this->userId, "http://example.com/9", "", ""], new ExceptionInput("constraintViolation"), $this->respGood(['code' => 0, 'feed_id' => 4])],
+ [[], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['feed_url' => "http://example.com/", 'login' => []], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['feed_url' => "http://example.com/", 'login' => "", 'password' => []], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['feed_url' => "http://example.com/", 'category_id' => -1], null, null, $this->respErr("INCORRECT_USAGE")],
+ ];
+ }
+
+ /** @dataProvider provideFeedUnsubscriptions */
+ public function testRemoveASubscription(array $in, ?array $data, $out, ResponseInterface $exp): void {
+ $in = array_merge(['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx"], $in);
+ $action = ($out instanceof \Exception) ? "throws" : "returns";
+ $this->dbMock->subscriptionRemove->$action($out);
+ $this->assertMessage($exp, $this->req($in));
+ if ($out !== null) {
+ $this->dbMock->subscriptionRemove->calledWith(...$data);
+ } else {
+ $this->dbMock->subscriptionRemove->never()->called();
}
- \Phake::verify(Arsse::$db, \Phake::times(0))->subscriptionPropertiesSet(Arsse::$user->id, 4, ['folder' => 1]);
}
- public function testRemoveASubscription(): void {
- $in = [
- ['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42],
- ['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112],
- ['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1],
- ['op' => "unsubscribeFeed", 'sid' => "PriestsOfSyrinx"],
+ public function provideFeedUnsubscriptions(): iterable {
+ return [
+ [['feed_id' => 42], [$this->userId, 42], true, $this->respGood(['status' => "OK"])],
+ [['feed_id' => 2112], [$this->userId, 2112], new ExceptionInput("subjectMissing"), $this->respErr("FEED_NOT_FOUND")],
+ [['feed_id' => -1], [$this->userId, -1], new ExceptionInput("typeViolation"), $this->respErr("FEED_NOT_FOUND")],
+ [[], [$this->userId, 0], new ExceptionInput("typeViolation"), $this->respErr("FEED_NOT_FOUND")],
];
- \Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, $this->anything())->thenThrow(new ExceptionInput("typeViolation"));
- \Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 2112)->thenThrow(new ExceptionInput("subjectMissing"));
- \Phake::when(Arsse::$db)->subscriptionRemove(Arsse::$user->id, 42)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing"));
- // succefully delete a folder
- $exp = $this->respGood(['status' => "OK"]);
- $this->assertMessage($exp, $this->req($in[0]));
- // try deleting it again (this should noisily fail, as should everything else)
- $exp = $this->respErr("FEED_NOT_FOUND");
- $this->assertMessage($exp, $this->req($in[0]));
- $this->assertMessage($exp, $this->req($in[1]));
- $this->assertMessage($exp, $this->req($in[2]));
- $this->assertMessage($exp, $this->req($in[3]));
- \Phake::verify(Arsse::$db, \Phake::times(5))->subscriptionRemove(Arsse::$user->id, $this->anything());
}
- public function testMoveASubscription(): void {
- $in = [
- ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'category_id' => 1],
- ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112, 'category_id' => 2],
- ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'category_id' => 0],
- ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'category_id' => 47],
- ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1, 'category_id' => 1],
- ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'category_id' => -1],
- ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42],
- ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx", 'category_id' => -1],
- ['op' => "moveFeed", 'sid' => "PriestsOfSyrinx"],
- ];
- $db = [
- [Arsse::$user->id, 42, ['folder' => 1]],
- [Arsse::$user->id, 2112, ['folder' => 2]],
- [Arsse::$user->id, 42, ['folder' => 0]],
- [Arsse::$user->id, 42, ['folder' => 47]],
- ];
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[0])->thenReturn(true);
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[1])->thenThrow(new ExceptionInput("subjectMissing"));
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation"));
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[3])->thenThrow(new ExceptionInput("constraintViolation"));
- // succefully move a subscription
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[0]));
- // move a subscription which does not exist (this should silently fail)
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[1]));
- // move a subscription causing a duplication (this should also silently fail)
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[2]));
- $this->assertMessage($exp, $this->req($in[3]));
- // all the rest should cause errors
- $exp = $this->respErr("INCORRECT_USAGE");
- $this->assertMessage($exp, $this->req($in[4]));
- $this->assertMessage($exp, $this->req($in[5]));
- $this->assertMessage($exp, $this->req($in[6]));
- $this->assertMessage($exp, $this->req($in[7]));
- $this->assertMessage($exp, $this->req($in[8]));
- \Phake::verify(Arsse::$db, \Phake::times(4))->subscriptionPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything());
+ /** @dataProvider provideFeedMoves */
+ public function testMoveAFeed(array $in, ?array $data, $out, ResponseInterface $exp): void {
+ $in = array_merge(['op' => "moveFeed", 'sid' => "PriestsOfSyrinx"], $in);
+ $action = ($out instanceof \Exception) ? "throws" : "returns";
+ $this->dbMock->subscriptionPropertiesSet->$action($out);
+ $this->assertMessage($exp, $this->req($in));
+ if ($out !== null) {
+ $this->dbMock->subscriptionPropertiesSet->calledWith(...$data);
+ } else {
+ $this->dbMock->subscriptionPropertiesSet->never()->called();
+ }
}
- public function testRenameASubscription(): void {
- $in = [
- ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'caption' => "Ook"],
- ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112, 'caption' => "Eek"],
- ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'caption' => "Eek"],
- ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'caption' => ""],
- ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'caption' => " "],
- ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1, 'caption' => "Ook"],
- ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42],
- ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx", 'caption' => "Ook"],
- ['op' => "renameFeed", 'sid' => "PriestsOfSyrinx"],
+ public function provideFeedMoves(): iterable {
+ return [
+ [['feed_id' => 42, 'category_id' => 1], [$this->userId, 42, ['folder' => 1]], true, $this->respGood()],
+ [['feed_id' => 2112, 'category_id' => 2], [$this->userId, 2112, ['folder' => 2]], new ExceptionInput("subjectMissing"), $this->respGood()],
+ [['feed_id' => 42, 'category_id' => 0], [$this->userId, 42, ['folder' => 0]], new ExceptionInput("constraintViolation"), $this->respGood()],
+ [['feed_id' => 42, 'category_id' => 47], [$this->userId, 42, ['folder' => 47]], new ExceptionInput("constraintViolation"), $this->respGood()],
+ [['feed_id' => -1, 'category_id' => 1], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['feed_id' => 42, 'category_id' => -1], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['feed_id' => 42], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['category_id' => -1], null, null, $this->respErr("INCORRECT_USAGE")],
+ [[], null, null, $this->respErr("INCORRECT_USAGE")],
];
- $db = [
- [Arsse::$user->id, 42, ['title' => "Ook"]],
- [Arsse::$user->id, 2112, ['title' => "Eek"]],
- [Arsse::$user->id, 42, ['title' => "Eek"]],
+ }
+
+ /** @dataProvider provideFeedRenamings */
+ public function testRenameAFeed(array $in, ?array $data, $out, ResponseInterface $exp): void {
+ $in = array_merge(['op' => "renameFeed", 'sid' => "PriestsOfSyrinx"], $in);
+ $action = ($out instanceof \Exception) ? "throws" : "returns";
+ $this->dbMock->subscriptionPropertiesSet->$action($out);
+ $this->assertMessage($exp, $this->req($in));
+ if ($out !== null) {
+ $this->dbMock->subscriptionPropertiesSet->calledWith(...$data);
+ } else {
+ $this->dbMock->subscriptionPropertiesSet->never()->called();
+ }
+ }
+
+ public function provideFeedRenamings(): iterable {
+ return [
+ [['feed_id' => 42, 'caption' => "Ook"], [$this->userId, 42, ['title' => "Ook"]], true, $this->respGood()],
+ [['feed_id' => 2112, 'caption' => "Eek"], [$this->userId, 2112, ['title' => "Eek"]], new ExceptionInput("subjectMissing"), $this->respGood()],
+ [['feed_id' => 42, 'caption' => "Eek"], [$this->userId, 42, ['title' => "Eek"]], new ExceptionInput("constraintViolation"), $this->respGood()],
+ [['feed_id' => 42, 'caption' => ""], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['feed_id' => 42, 'caption' => " "], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['feed_id' => -1, 'caption' => "Ook"], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['feed_id' => 42], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['caption' => "Ook"], null, null, $this->respErr("INCORRECT_USAGE")],
+ [[], null, null, $this->respErr("INCORRECT_USAGE")],
];
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[0])->thenReturn(true);
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[1])->thenThrow(new ExceptionInput("subjectMissing"));
- \Phake::when(Arsse::$db)->subscriptionPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation"));
- // succefully rename a subscription
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[0]));
- // rename a subscription which does not exist (this should silently fail)
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[1]));
- // rename a subscription causing a duplication (this should also silently fail)
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[2]));
- // all the rest should cause errors
- $exp = $this->respErr("INCORRECT_USAGE");
- $this->assertMessage($exp, $this->req($in[3]));
- $this->assertMessage($exp, $this->req($in[4]));
- $this->assertMessage($exp, $this->req($in[5]));
- $this->assertMessage($exp, $this->req($in[6]));
- $this->assertMessage($exp, $this->req($in[7]));
- $this->assertMessage($exp, $this->req($in[8]));
- \Phake::verify(Arsse::$db)->subscriptionPropertiesSet(...$db[0]);
- \Phake::verify(Arsse::$db)->subscriptionPropertiesSet(...$db[1]);
- \Phake::verify(Arsse::$db)->subscriptionPropertiesSet(...$db[2]);
}
public function testRetrieveTheGlobalUnreadCount(): void {
$in = ['op' => "getUnread", 'sid' => "PriestsOfSyrinx"];
- \Phake::when(Arsse::$db)->subscriptionList(Arsse::$user->id)->thenReturn(new Result($this->v([
+ $this->dbMock->subscriptionList->returns(new Result($this->v([
['id' => 1, 'unread' => 2112],
['id' => 2, 'unread' => 42],
['id' => 3, 'unread' => 47],
@@ -950,171 +835,136 @@ LONG_STRING;
$interval = Arsse::$conf->serviceFrequency;
$valid = (new \DateTimeImmutable("now", new \DateTimezone("UTC")))->sub($interval);
$invalid = $valid->sub($interval)->sub($interval);
- \Phake::when(Arsse::$db)->metaGet("service_last_checkin")->thenReturn(Date::transform($valid, "sql"))->thenReturn(Date::transform($invalid, "sql"));
- \Phake::when(Arsse::$db)->subscriptionCount(Arsse::$user->id)->thenReturn(12)->thenReturn(2);
- $exp = [
- ['icons_dir' => "feed-icons", 'icons_url' => "feed-icons", 'daemon_is_running' => true, 'num_feeds' => 12],
- ['icons_dir' => "feed-icons", 'icons_url' => "feed-icons", 'daemon_is_running' => false, 'num_feeds' => 2],
- ];
- $this->assertMessage($this->respGood($exp[0]), $this->req($in));
- $this->assertMessage($this->respGood($exp[1]), $this->req($in));
+ $this->dbMock->metaGet->with("service_last_checkin")->returns(Date::transform($valid, "sql"), Date::transform($invalid, "sql"));
+ $this->dbMock->subscriptionCount->with($this->userId)->returns(12, 2);
+ $this->assertMessage($this->respGood(['icons_dir' => "feed-icons", 'icons_url' => "feed-icons", 'daemon_is_running' => true, 'num_feeds' => 12]), $this->req($in));
+ $this->assertMessage($this->respGood(['icons_dir' => "feed-icons", 'icons_url' => "feed-icons", 'daemon_is_running' => false, 'num_feeds' => 2]), $this->req($in));
}
- public function testUpdateAFeed(): void {
- $in = [
- ['op' => "updateFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 1],
- ['op' => "updateFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2],
- ['op' => "updateFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1],
- ['op' => "updateFeed", 'sid' => "PriestsOfSyrinx"],
- ];
- \Phake::when(Arsse::$db)->feedUpdate(11)->thenReturn(true);
- \Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 1)->thenReturn($this->v(['id' => 1, 'feed' => 11]));
- \Phake::when(Arsse::$db)->subscriptionPropertiesGet(Arsse::$user->id, 2)->thenThrow(new ExceptionInput("subjectMissing"));
- $exp = $this->respGood(['status' => "OK"]);
- $this->assertMessage($exp, $this->req($in[0]));
- \Phake::verify(Arsse::$db)->feedUpdate(11);
- $exp = $this->respErr("FEED_NOT_FOUND");
- $this->assertMessage($exp, $this->req($in[1]));
- $exp = $this->respErr("INCORRECT_USAGE");
- $this->assertMessage($exp, $this->req($in[2]));
- $this->assertMessage($exp, $this->req($in[3]));
+ /** @dataProvider provideFeedUpdates */
+ public function testUpdateAFeed(array $in, ?array $data, $out, ?int $id, ResponseInterface $exp): void {
+ $in = array_merge(['op' => "updateFeed", 'sid' => "PriestsOfSyrinx"], $in);
+ $action = ($out instanceof \Exception) ? "throws" : "returns";
+ $this->dbMock->subscriptionPropertiesGet->$action($out);
+ $this->dbMock->feedUpdate->returns(true);
+ $this->assertMessage($exp, $this->req($in));
+ if ($data !== null) {
+ $this->dbMock->subscriptionPropertiesGet->calledWith(...$data);
+ } else {
+ $this->dbMock->subscriptionPropertiesGet->never()->called();
+ }
+ if ($id !== null) {
+ $this->dbMock->feedUpdate->calledWith($id);
+ } else {
+ $this->dbMock->feedUpdate->never()->called();
+ }
}
- public function testAddALabel(): void {
- $in = [
- ['op' => "addLabel", 'sid' => "PriestsOfSyrinx", 'caption' => "Software"],
- ['op' => "addLabel", 'sid' => "PriestsOfSyrinx", 'caption' => "Hardware"],
- ['op' => "addLabel", 'sid' => "PriestsOfSyrinx"],
- ['op' => "addLabel", 'sid' => "PriestsOfSyrinx", 'caption' => ""],
- ['op' => "addLabel", 'sid' => "PriestsOfSyrinx", 'caption' => " "],
+ public function provideFeedUpdates(): iterable {
+ return [
+ [['feed_id' => 1], [$this->userId, 1], $this->v(['id' => 1, 'feed' => 11]), 11, $this->respGood(['status' => "OK"])],
+ [['feed_id' => 2], [$this->userId, 2], new ExceptionInput("subjectMissing"), null, $this->respErr("FEED_NOT_FOUND")],
+ [['feed_id' => -1], null, null, null, $this->respErr("INCORRECT_USAGE")],
+ [[], null, null, null, $this->respErr("INCORRECT_USAGE")],
];
- $db = [
- ['name' => "Software"],
- ['name' => "Hardware"],
- ];
- $out = [
- ['id' => 2, 'name' => "Software"],
- ['id' => 3, 'name' => "Hardware"],
- ['id' => 1, 'name' => "Politics"],
- ];
- $labelOffset = (new \ReflectionClassConstant(API::class, "LABEL_OFFSET"))->getValue();
- // set of various mocks for testing
- \Phake::when(Arsse::$db)->labelAdd(Arsse::$user->id, $db[0])->thenReturn(2)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call
- \Phake::when(Arsse::$db)->labelAdd(Arsse::$user->id, $db[1])->thenReturn(3)->thenThrow(new ExceptionInput("constraintViolation")); // error on the second call
- \Phake::when(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Software", true)->thenReturn($this->v($out[0]));
- \Phake::when(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Hardware", true)->thenReturn($this->v($out[1]));
- // set up mocks that produce errors
- \Phake::when(Arsse::$db)->labelAdd(Arsse::$user->id, [])->thenThrow(new ExceptionInput("missing"));
- \Phake::when(Arsse::$db)->labelAdd(Arsse::$user->id, ['name' => ""])->thenThrow(new ExceptionInput("missing"));
- \Phake::when(Arsse::$db)->labelAdd(Arsse::$user->id, ['name' => " "])->thenThrow(new ExceptionInput("whitespace"));
- // correctly add two labels
- $exp = $this->respGood((-1 * $labelOffset) - 2);
- $this->assertMessage($exp, $this->req($in[0]));
- $exp = $this->respGood((-1 * $labelOffset) - 3);
- $this->assertMessage($exp, $this->req($in[1]));
- // attempt to add the two labels again
- $exp = $this->respGood((-1 * $labelOffset) - 2);
- $this->assertMessage($exp, $this->req($in[0]));
- $exp = $this->respGood((-1 * $labelOffset) - 3);
- $this->assertMessage($exp, $this->req($in[1]));
- \Phake::verify(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Software", true);
- \Phake::verify(Arsse::$db)->labelPropertiesGet(Arsse::$user->id, "Hardware", true);
- // add some invalid labels
- $exp = $this->respErr("INCORRECT_USAGE");
- $this->assertMessage($exp, $this->req($in[2]));
- $this->assertMessage($exp, $this->req($in[3]));
- $this->assertMessage($exp, $this->req($in[4]));
}
- public function testRemoveALabel(): void {
- $in = [
- ['op' => "removeLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -1042],
- ['op' => "removeLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -2112],
- ['op' => "removeLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => 1],
- ['op' => "removeLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => 0],
- ['op' => "removeLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -10],
- ];
- \Phake::when(Arsse::$db)->labelRemove(Arsse::$user->id, $this->anything())->thenThrow(new ExceptionInput("subjectMissing"));
- \Phake::when(Arsse::$db)->labelRemove(Arsse::$user->id, 18)->thenReturn(true)->thenThrow(new ExceptionInput("subjectMissing"));
- // succefully delete a label
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[0]));
- // try deleting it again (this should silently fail)
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[0]));
- // delete a label which does not exist (this should also silently fail)
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[1]));
- // delete some invalid labels (causes an error)
- $exp = $this->respErr("INCORRECT_USAGE");
- $this->assertMessage($exp, $this->req($in[2]));
- $this->assertMessage($exp, $this->req($in[3]));
- $this->assertMessage($exp, $this->req($in[4]));
- \Phake::verify(Arsse::$db, \Phake::times(2))->labelRemove(Arsse::$user->id, 18);
- \Phake::verify(Arsse::$db)->labelRemove(Arsse::$user->id, 1088);
+ /** @dataProvider provideLabelAdditions */
+ public function testAddALabel(array $in, ?array $data1, $out1, ?array $data2, $out2, ResponseInterface $exp): void {
+ $in = array_merge(['op' => "addLabel", 'sid' => "PriestsOfSyrinx"], $in);
+ $action = ($out1 instanceof \Exception) ? "throws" : "returns";
+ $this->dbMock->labelAdd->$action($out1);
+ $this->dbMock->labelPropertiesGet->returns($out2);
+ $this->assertMessage($exp, $this->req($in));
+ if ($out1 !== null) {
+ $this->dbMock->labelAdd->calledWith(...$data1);
+ } else {
+ $this->dbMock->labelAdd->never()->called();
+ }
+ if ($out2 !== null) {
+ $this->dbMock->labelPropertiesGet->calledWith(...$data2);
+ } else {
+ $this->dbMock->labelPropertiesGet->never()->called();
+ }
}
- public function testRenameALabel(): void {
- $in = [
- ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -1042, 'caption' => "Ook"],
- ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -2112, 'caption' => "Eek"],
- ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -1042, 'caption' => "Eek"],
- ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -1042, 'caption' => ""],
- ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -1042, 'caption' => " "],
- ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -1042],
- ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -1, 'caption' => "Ook"],
- ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx", 'caption' => "Ook"],
- ['op' => "renameLabel", 'sid' => "PriestsOfSyrinx"],
+ public function provideLabelAdditions(): iterable {
+ return [
+ [['caption' => "Software"], [$this->userId, ['name' => "Software"]], 2, null, null, $this->respGood(-1026)],
+ [['caption' => "Hardware"], [$this->userId, ['name' => "Hardware"]], 3, null, null, $this->respGood(-1027)],
+ [['caption' => "Software"], [$this->userId, ['name' => "Software"]], new ExceptionInput("constraintViolation"), [$this->userId, "Software", true], ['id' => 2], $this->respGood(-1026)],
+ [['caption' => "Hardware"], [$this->userId, ['name' => "Hardware"]], new ExceptionInput("constraintViolation"), [$this->userId, "Hardware", true], ['id' => 3], $this->respGood(-1027)],
+ [[], [$this->userId, ['name' => ""]], new ExceptionInput("typeViolation"), null, null, $this->respErr("INCORRECT_USAGE")],
+ [['caption' => ""], [$this->userId, ['name' => ""]], new ExceptionInput("typeViolation"), null, null, $this->respErr("INCORRECT_USAGE")],
+ [['caption' => " "], [$this->userId, ['name' => " "]], new ExceptionInput("typeViolation"), null, null, $this->respErr("INCORRECT_USAGE")],
];
- $db = [
- [Arsse::$user->id, 18, ['name' => "Ook"]],
- [Arsse::$user->id, 1088, ['name' => "Eek"]],
- [Arsse::$user->id, 18, ['name' => "Eek"]],
- [Arsse::$user->id, 18, ['name' => ""]],
- [Arsse::$user->id, 18, ['name' => " "]],
- [Arsse::$user->id, 18, ['name' => ""]],
- ];
- \Phake::when(Arsse::$db)->labelPropertiesSet(...$db[0])->thenReturn(true);
- \Phake::when(Arsse::$db)->labelPropertiesSet(...$db[1])->thenThrow(new ExceptionInput("subjectMissing"));
- \Phake::when(Arsse::$db)->labelPropertiesSet(...$db[2])->thenThrow(new ExceptionInput("constraintViolation"));
- \Phake::when(Arsse::$db)->labelPropertiesSet(...$db[3])->thenThrow(new ExceptionInput("typeViolation"));
- \Phake::when(Arsse::$db)->labelPropertiesSet(...$db[4])->thenThrow(new ExceptionInput("typeViolation"));
- \Phake::when(Arsse::$db)->labelPropertiesSet(...$db[5])->thenThrow(new ExceptionInput("typeViolation"));
- // succefully rename a label
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[0]));
- // rename a label which does not exist (this should silently fail)
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[1]));
- // rename a label causing a duplication (this should also silently fail)
- $exp = $this->respGood();
- $this->assertMessage($exp, $this->req($in[2]));
- // all the rest should cause errors
- $exp = $this->respErr("INCORRECT_USAGE");
- $this->assertMessage($exp, $this->req($in[3]));
- $this->assertMessage($exp, $this->req($in[4]));
- $this->assertMessage($exp, $this->req($in[5]));
- $this->assertMessage($exp, $this->req($in[6]));
- $this->assertMessage($exp, $this->req($in[7]));
- $this->assertMessage($exp, $this->req($in[8]));
- \Phake::verify(Arsse::$db, \Phake::times(6))->labelPropertiesSet(Arsse::$user->id, $this->anything(), $this->anything());
}
- public function testRetrieveCategoryLists(): void {
- $in = [
- ['op' => "getCategories", 'sid' => "PriestsOfSyrinx", 'include_empty' => true],
- ['op' => "getCategories", 'sid' => "PriestsOfSyrinx"],
- ['op' => "getCategories", 'sid' => "PriestsOfSyrinx", 'unread_only' => true],
- ['op' => "getCategories", 'sid' => "PriestsOfSyrinx", 'enable_nested' => true, 'include_empty' => true],
- ['op' => "getCategories", 'sid' => "PriestsOfSyrinx", 'enable_nested' => true],
- ['op' => "getCategories", 'sid' => "PriestsOfSyrinx", 'enable_nested' => true, 'unread_only' => true],
+ /** @dataProvider provideLabelRemovals */
+ public function testRemoveALabel(array $in, ?array $data, $out, ResponseInterface $exp): void {
+ $in = array_merge(['op' => "removeLabel", 'sid' => "PriestsOfSyrinx"], $in);
+ $action = ($out instanceof \Exception) ? "throws" : "returns";
+ $this->dbMock->labelRemove->$action($out);
+ $this->assertMessage($exp, $this->req($in));
+ if ($out !== null) {
+ $this->dbMock->labelRemove->calledWith(...$data);
+ } else {
+ $this->dbMock->labelRemove->never()->called();
+ }
+ }
+
+ public function provideLabelRemovals(): iterable {
+ return [
+ [['label_id' => -1042], [$this->userId, 18], true, $this->respGood()],
+ [['label_id' => -2112], [$this->userId, 1088], new ExceptionInput("subjectMissing"), $this->respGood()],
+ [['label_id' => 1], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['label_id' => 0], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['label_id' => -10], null, null, $this->respErr("INCORRECT_USAGE")],
+ [[], null, null, $this->respErr("INCORRECT_USAGE")],
];
- \Phake::when(Arsse::$db)->folderList($this->anything(), null, true)->thenReturn(new Result($this->v($this->folders)));
- \Phake::when(Arsse::$db)->folderList($this->anything(), null, false)->thenReturn(new Result($this->v($this->topFolders)));
- \Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($this->v($this->subscriptions)));
- \Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->v($this->labels)));
- \Phake::when(Arsse::$db)->articleCount($this->anything(), $this->equalTo((new Context)->unread(true)->modifiedSince(Date::sub("PT24H")), 2))->thenReturn(7);
- \Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->v($this->starred));
+ }
+
+ /** @dataProvider provideLabelRenamings */
+ public function testRenameALabel(array $in, ?array $data, $out, ResponseInterface $exp): void {
+ $in = array_merge(['op' => "renameLabel", 'sid' => "PriestsOfSyrinx"], $in);
+ $action = ($out instanceof \Exception) ? "throws" : "returns";
+ $this->dbMock->labelPropertiesSet->$action($out);
+ $this->assertMessage($exp, $this->req($in));
+ if ($out !== null) {
+ $this->dbMock->labelPropertiesSet->calledWith(...$data);
+ } else {
+ $this->dbMock->labelPropertiesSet->never()->called();
+ }
+ }
+
+ public function provideLabelRenamings(): iterable {
+ return [
+ [['label_id' => -1042, 'caption' => "Ook"], [$this->userId, 18, ['name' => "Ook"]], true, $this->respGood()],
+ [['label_id' => -2112, 'caption' => "Eek"], [$this->userId, 1088, ['name' => "Eek"]], new ExceptionInput("subjectMissing"), $this->respGood()],
+ [['label_id' => -1042, 'caption' => "Eek"], [$this->userId, 18, ['name' => "Eek"]], new ExceptionInput("constraintViolation"), $this->respGood()],
+ [['label_id' => -1042, 'caption' => ""], [$this->userId, 18, ['name' => ""]], new ExceptionInput("missing"), $this->respGood()],
+ [['label_id' => -1042, 'caption' => " "], [$this->userId, 18, ['name' => " "]], new ExceptionInput("whitespace"), $this->respGood()],
+ [['label_id' => -1042], [$this->userId, 18, ['name' => ""]], new ExceptionInput("missing"), $this->respGood()],
+ [['label_id' => -1042], [$this->userId, 18, ['name' => ""]], new ExceptionInput("typeViolation"), $this->respErr("INCORRECT_USAGE")],
+ [['label_id' => -1, 'caption' => "Ook"], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['caption' => "Ook"], null, null, $this->respErr("INCORRECT_USAGE")],
+ [[], null, null, $this->respErr("INCORRECT_USAGE")],
+ ];
+ }
+
+ /** @dataProvider provideCategoryListings */
+ public function testRetrieveCategoryLists(array $in, ResponseInterface $exp): void {
+ $in = array_merge(['op' => "getCategories", 'sid' => "PriestsOfSyrinx"], $in);
+ $this->dbMock->folderList->with("~", null, true)->returns(new Result($this->v($this->folders)));
+ $this->dbMock->folderList->with("~", null, false)->returns(new Result($this->v($this->topFolders)));
+ $this->dbMock->subscriptionList->returns(new Result($this->v($this->subscriptions)));
+ $this->dbMock->labelList->returns(new Result($this->v($this->labels)));
+ $this->dbMock->articleCount->with("~", $this->equalTo((new Context)->hidden(false)->unread(true)->modifiedSince(Date::sub("PT24H", self::NOW))))->returns(7);
+ $this->dbMock->articleStarred->returns($this->v($this->starred));
+ $this->assertMessage($exp, $this->req($in));
+ }
+
+ public function provideCategoryListings(): iterable {
$exp = [
[
['id' => "5", 'title' => "Local", 'unread' => 10, 'order_id' => 1],
@@ -1167,18 +1017,23 @@ LONG_STRING;
['id' => -2, 'title' => "Labels", 'unread' => "6"],
],
];
- for ($a = 0; $a < sizeof($in); $a++) {
- $this->assertMessage($this->respGood($exp[$a]), $this->req($in[$a]), "Test $a failed");
- }
+ return [
+ [['include_empty' => true], $this->respGood($exp[0])],
+ [[], $this->respGood($exp[1])],
+ [['unread_only' => true], $this->respGood($exp[2])],
+ [['enable_nested' => true, 'include_empty' => true], $this->respGood($exp[3])],
+ [['enable_nested' => true], $this->respGood($exp[4])],
+ [['enable_nested' => true, 'unread_only' => true], $this->respGood($exp[5])],
+ ];
}
public function testRetrieveCounterList(): void {
$in = ['op' => "getCounters", 'sid' => "PriestsOfSyrinx"];
- \Phake::when(Arsse::$db)->folderList($this->anything())->thenReturn(new Result($this->v($this->folders)));
- \Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($this->v($this->subscriptions)));
- \Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->v($this->usedLabels)));
- \Phake::when(Arsse::$db)->articleCount($this->anything(), $this->equalTo((new Context)->unread(true)->modifiedSince(Date::sub("PT24H")), 2))->thenReturn(7);
- \Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->v($this->starred));
+ $this->dbMock->folderList->returns(new Result($this->v($this->folders)));
+ $this->dbMock->subscriptionList->returns(new Result($this->v($this->subscriptions)));
+ $this->dbMock->labelList->with("~", false)->returns(new Result($this->v($this->usedLabels)));
+ $this->dbMock->articleCount->returns(7);
+ $this->dbMock->articleStarred->returns($this->v($this->starred));
$exp = [
['id' => "global-unread", 'counter' => 35],
['id' => "subscribed-feeds", 'counter' => 6],
@@ -1205,21 +1060,21 @@ LONG_STRING;
['id' => -2, 'kind' => "cat", 'counter' => 6],
];
$this->assertMessage($this->respGood($exp), $this->req($in));
+ $this->dbMock->articleCount->calledWith($this->userId, $this->equalTo((new Context)->hidden(false)->unread(true)->modifiedSince(Date::sub("PT24H", self::NOW))));
}
- public function testRetrieveTheLabelList(): void {
- $in = [
- ['op' => "getLabels", 'sid' => "PriestsOfSyrinx"],
- ['op' => "getLabels", 'sid' => "PriestsOfSyrinx", 'article_id' => 1],
- ['op' => "getLabels", 'sid' => "PriestsOfSyrinx", 'article_id' => 2],
- ['op' => "getLabels", 'sid' => "PriestsOfSyrinx", 'article_id' => 3],
- ['op' => "getLabels", 'sid' => "PriestsOfSyrinx", 'article_id' => 4],
- ];
- \Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->v($this->labels)));
- \Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 1)->thenReturn($this->v([1,3]));
- \Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 2)->thenReturn($this->v([3]));
- \Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 3)->thenReturn([]);
- \Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 4)->thenThrow(new ExceptionInput("idMissing"));
+ /** @dataProvider provideLabelListings */
+ public function testRetrieveTheLabelList(array $in, ResponseInterface $exp): void {
+ $in = array_merge(['op' => "getLabels", 'sid' => "PriestsOfSyrinx"], $in);
+ $this->dbMock->labelList->returns(new Result($this->v($this->labels)));
+ $this->dbMock->articleLabelsGet->with("~", 1)->returns($this->v([1,3]));
+ $this->dbMock->articleLabelsGet->with("~", 2)->returns($this->v([3]));
+ $this->dbMock->articleLabelsGet->with("~", 3)->returns([]);
+ $this->dbMock->articleLabelsGet->with("~", 4)->throws(new ExceptionInput("idMissing"));
+ $this->assertMessage($exp, $this->req($in));
+ }
+
+ public function provideLabelListings(): iterable {
$exp = [
[
['id' => -1027, 'caption' => "Fascinating", 'fg_color' => "", 'bg_color' => "", 'checked' => false],
@@ -1247,47 +1102,39 @@ LONG_STRING;
['id' => -1025, 'caption' => "Logical", 'fg_color' => "", 'bg_color' => "", 'checked' => false],
],
];
- for ($a = 0; $a < sizeof($in); $a++) {
- $this->assertMessage($this->respGood($exp[$a]), $this->req($in[$a]), "Test $a failed");
- }
+ return [
+ [[], $this->respGood($exp[0])],
+ [['article_id' => 1], $this->respGood($exp[1])],
+ [['article_id' => 2], $this->respGood($exp[2])],
+ [['article_id' => 3], $this->respGood($exp[3])],
+ [['article_id' => 4], $this->respGood($exp[4])],
+ ];
}
- public function testAssignArticlesToALabel(): void {
- $list = [
- range(1, 100),
- range(1, 50),
- range(51, 100),
+ public function provideLabelAssignments(): iterable {
+ $ids = implode(",", range(1, 100));
+ return [
+ [['label_id' => -2112, 'article_ids' => $ids], 1088, Database::ASSOC_REMOVE, $this->respGood(['status' => "OK", 'updated' => 89])],
+ [['label_id' => -2112, 'article_ids' => $ids, 'assign' => true], 1088, Database::ASSOC_ADD, $this->respGood(['status' => "OK", 'updated' => 7])],
+ [['label_id' => -2112], null, null, $this->respGood(['status' => "OK", 'updated' => 0])],
+ [['label_id' => -42], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['label_id' => 42], null, null, $this->respErr("INCORRECT_USAGE")],
+ [['label_id' => 0], null, null, $this->respErr("INCORRECT_USAGE")],
+ [[], null, null, $this->respErr("INCORRECT_USAGE")],
];
- $in = [
- ['op' => "setArticleLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -2112, 'article_ids' => implode(",", $list[0])],
- ['op' => "setArticleLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -2112, 'article_ids' => implode(",", $list[0]), 'assign' => true],
- ['op' => "setArticleLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -2112],
- ['op' => "setArticleLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => -42],
- ['op' => "setArticleLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => 42],
- ['op' => "setArticleLabel", 'sid' => "PriestsOfSyrinx", 'label_id' => 0],
- ['op' => "setArticleLabel", 'sid' => "PriestsOfSyrinx"],
- ];
- \Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, $this->anything(), (new Context)->articles([]), $this->anything())->thenThrow(new ExceptionInput("tooShort")); // data model function requires one valid integer for multiples
- \Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, $this->anything(), (new Context)->articles($list[0]), $this->anything())->thenThrow(new ExceptionInput("tooLong")); // data model function limited to 50 items for multiples
- \Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_REMOVE)->thenReturn(42);
- \Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_REMOVE)->thenReturn(47);
- \Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_ADD)->thenReturn(5);
- \Phake::when(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_ADD)->thenReturn(2);
- $exp = $this->respGood(['status' => "OK", 'updated' => 89]);
- $this->assertMessage($exp, $this->req($in[0]));
- \Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_REMOVE);
- \Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_REMOVE);
- $exp = $this->respGood(['status' => "OK", 'updated' => 7]);
- $this->assertMessage($exp, $this->req($in[1]));
- \Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[1]), Database::ASSOC_ADD);
- \Phake::verify(Arsse::$db)->labelArticlesSet(Arsse::$user->id, 1088, (new Context)->articles($list[2]), Database::ASSOC_ADD);
- $exp = $this->respGood(['status' => "OK", 'updated' => 0]);
- $this->assertMessage($exp, $this->req($in[2]));
- $exp = $this->respErr("INCORRECT_USAGE");
- $this->assertMessage($exp, $this->req($in[3]));
- $this->assertMessage($exp, $this->req($in[4]));
- $this->assertMessage($exp, $this->req($in[5]));
- $this->assertMessage($exp, $this->req($in[6]));
+ }
+
+ /** @dataProvider provideLabelAssignments */
+ public function testAssignArticlesToALabel(array $in, ?int $label, ?int $operation, ResponseInterface $exp): void {
+ $in = array_merge(['op' => "setArticleLabel", 'sid' => "PriestsOfSyrinx"], $in);
+ $this->dbMock->labelArticlesSet->with($this->userId, "~", "~", Database::ASSOC_REMOVE)->returns(42)->returns(47);
+ $this->dbMock->labelArticlesSet->with($this->userId, "~", "~", Database::ASSOC_ADD)->returns(5)->returns(2);
+ $this->dbMock->labelArticlesSet->with($this->userId, "~", $this->equalTo((new Context)->articles([])), "~")->throws(new ExceptionInput("tooShort"));
+ $this->assertMessage($exp, $this->req($in));
+ if ($label !== null) {
+ $this->dbMock->labelArticlesSet->calledWith($this->userId, $label, $this->equalTo((new Context)->articles(range(1, 50))), $operation);
+ $this->dbMock->labelArticlesSet->calledWith($this->userId, $label, $this->equalTo((new Context)->articles(range(51, 100))), $operation);
+ }
}
public function testRetrieveFeedTree(): void {
@@ -1295,114 +1142,113 @@ LONG_STRING;
['op' => "getFeedTree", 'sid' => "PriestsOfSyrinx", 'include_empty' => true],
['op' => "getFeedTree", 'sid' => "PriestsOfSyrinx"],
];
- \Phake::when(Arsse::$db)->folderList($this->anything(), null, true)->thenReturn(new Result($this->v($this->folders)));
- \Phake::when(Arsse::$db)->subscriptionList($this->anything())->thenReturn(new Result($this->v($this->subscriptions)));
- \Phake::when(Arsse::$db)->labelList($this->anything(), true)->thenReturn(new Result($this->v($this->labels)));
- \Phake::when(Arsse::$db)->articleCount($this->anything(), $this->equalTo((new Context)->unread(true)->modifiedSince(Date::sub("PT24H")), 2))->thenReturn(7);
- \Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->v($this->starred));
+ $this->dbMock->folderList->with("~", null, true)->returns(new Result($this->v($this->folders)));
+ $this->dbMock->subscriptionList->returns(new Result($this->v($this->subscriptions)));
+ $this->dbMock->labelList->with("~", true)->returns(new Result($this->v($this->labels)));
+ $this->dbMock->articleCount->returns(7);
+ $this->dbMock->articleStarred->returns($this->v($this->starred));
// the expectations are packed tightly since they're very verbose; one can use var_export() (or convert to JSON) to pretty-print them
$exp = ['categories' => ['identifier' => 'id','label' => 'name','items' => [['name' => 'Special','id' => 'CAT:-1','bare_id' => -1,'type' => 'category','unread' => 0,'items' => [['name' => 'All articles','id' => 'FEED:-4','bare_id' => -4,'icon' => 'images/folder.png','unread' => 35,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Fresh articles','id' => 'FEED:-3','bare_id' => -3,'icon' => 'images/fresh.png','unread' => 7,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Starred articles','id' => 'FEED:-1','bare_id' => -1,'icon' => 'images/star.png','unread' => 4,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Published articles','id' => 'FEED:-2','bare_id' => -2,'icon' => 'images/feed.png','unread' => 0,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Archived articles','id' => 'FEED:0','bare_id' => 0,'icon' => 'images/archive.png','unread' => 0,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Recently read','id' => 'FEED:-6','bare_id' => -6,'icon' => 'images/time.png','unread' => 0,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => '']]],['name' => 'Labels','id' => 'CAT:-2','bare_id' => -2,'type' => 'category','unread' => 6,'items' => [['name' => 'Fascinating','id' => 'FEED:-1027','bare_id' => -1027,'unread' => 0,'icon' => 'images/label.png','type' => 'feed','auxcounter' => 0,'error' => '','updated' => '','fg_color' => '','bg_color' => ''],['name' => 'Interesting','id' => 'FEED:-1029','bare_id' => -1029,'unread' => 0,'icon' => 'images/label.png','type' => 'feed','auxcounter' => 0,'error' => '','updated' => '','fg_color' => '','bg_color' => ''],['name' => 'Logical','id' => 'FEED:-1025','bare_id' => -1025,'unread' => 0,'icon' => 'images/label.png','type' => 'feed','auxcounter' => 0,'error' => '','updated' => '','fg_color' => '','bg_color' => '']]],['name' => 'Photography','id' => 'CAT:4','bare_id' => 4,'parent_id' => null,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(0 feeds)','items' => []],['name' => 'Politics','id' => 'CAT:3','bare_id' => 3,'parent_id' => null,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(3 feeds)','items' => [['name' => 'Local','id' => 'CAT:5','bare_id' => 5,'parent_id' => 3,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(1 feed)','items' => [['name' => 'Toronto Star','id' => 'FEED:2','bare_id' => 2,'icon' => 'feed-icons/2.ico','error' => 'oops','param' => '2011-11-11T11:11:11Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]],['name' => 'National','id' => 'CAT:6','bare_id' => 6,'parent_id' => 3,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(2 feeds)','items' => [['name' => 'CBC News','id' => 'FEED:4','bare_id' => 4,'icon' => 'feed-icons/4.ico','error' => '','param' => '2017-10-09T15:58:34Z','unread' => 0,'auxcounter' => 0,'checkbox' => false],['name' => 'Ottawa Citizen','id' => 'FEED:5','bare_id' => 5,'icon' => false,'error' => '','param' => '2017-07-07T17:07:17Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]]]],['name' => 'Science','id' => 'CAT:1','bare_id' => 1,'parent_id' => null,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(2 feeds)','items' => [['name' => 'Rocketry','id' => 'CAT:2','bare_id' => 2,'parent_id' => 1,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(1 feed)','items' => [['name' => 'NASA JPL','id' => 'FEED:1','bare_id' => 1,'icon' => false,'error' => '','param' => '2017-09-15T22:54:16Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]],['name' => 'Ars Technica','id' => 'FEED:3','bare_id' => 3,'icon' => 'feed-icons/3.ico','error' => 'argh','param' => '2016-05-23T06:40:02Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]],['name' => 'Uncategorized','id' => 'CAT:0','bare_id' => 0,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'parent_id' => null,'param' => '(1 feed)','items' => [['name' => 'Eurogamer','id' => 'FEED:6','bare_id' => 6,'icon' => 'feed-icons/6.ico','error' => '','param' => '2010-02-12T20:08:47Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]]]]];
$this->assertMessage($this->respGood($exp), $this->req($in[0]));
$exp = ['categories' => ['identifier' => 'id','label' => 'name','items' => [['name' => 'Special','id' => 'CAT:-1','bare_id' => -1,'type' => 'category','unread' => 0,'items' => [['name' => 'All articles','id' => 'FEED:-4','bare_id' => -4,'icon' => 'images/folder.png','unread' => 35,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Fresh articles','id' => 'FEED:-3','bare_id' => -3,'icon' => 'images/fresh.png','unread' => 7,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Starred articles','id' => 'FEED:-1','bare_id' => -1,'icon' => 'images/star.png','unread' => 4,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Published articles','id' => 'FEED:-2','bare_id' => -2,'icon' => 'images/feed.png','unread' => 0,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Archived articles','id' => 'FEED:0','bare_id' => 0,'icon' => 'images/archive.png','unread' => 0,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => ''],['name' => 'Recently read','id' => 'FEED:-6','bare_id' => -6,'icon' => 'images/time.png','unread' => 0,'type' => 'feed','auxcounter' => 0,'error' => '','updated' => '']]],['name' => 'Labels','id' => 'CAT:-2','bare_id' => -2,'type' => 'category','unread' => 6,'items' => [['name' => 'Fascinating','id' => 'FEED:-1027','bare_id' => -1027,'unread' => 0,'icon' => 'images/label.png','type' => 'feed','auxcounter' => 0,'error' => '','updated' => '','fg_color' => '','bg_color' => ''],['name' => 'Interesting','id' => 'FEED:-1029','bare_id' => -1029,'unread' => 0,'icon' => 'images/label.png','type' => 'feed','auxcounter' => 0,'error' => '','updated' => '','fg_color' => '','bg_color' => ''],['name' => 'Logical','id' => 'FEED:-1025','bare_id' => -1025,'unread' => 0,'icon' => 'images/label.png','type' => 'feed','auxcounter' => 0,'error' => '','updated' => '','fg_color' => '','bg_color' => '']]],['name' => 'Politics','id' => 'CAT:3','bare_id' => 3,'parent_id' => null,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(3 feeds)','items' => [['name' => 'Local','id' => 'CAT:5','bare_id' => 5,'parent_id' => 3,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(1 feed)','items' => [['name' => 'Toronto Star','id' => 'FEED:2','bare_id' => 2,'icon' => 'feed-icons/2.ico','error' => 'oops','param' => '2011-11-11T11:11:11Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]],['name' => 'National','id' => 'CAT:6','bare_id' => 6,'parent_id' => 3,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(2 feeds)','items' => [['name' => 'CBC News','id' => 'FEED:4','bare_id' => 4,'icon' => 'feed-icons/4.ico','error' => '','param' => '2017-10-09T15:58:34Z','unread' => 0,'auxcounter' => 0,'checkbox' => false],['name' => 'Ottawa Citizen','id' => 'FEED:5','bare_id' => 5,'icon' => false,'error' => '','param' => '2017-07-07T17:07:17Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]]]],['name' => 'Science','id' => 'CAT:1','bare_id' => 1,'parent_id' => null,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(2 feeds)','items' => [['name' => 'Rocketry','id' => 'CAT:2','bare_id' => 2,'parent_id' => 1,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'param' => '(1 feed)','items' => [['name' => 'NASA JPL','id' => 'FEED:1','bare_id' => 1,'icon' => false,'error' => '','param' => '2017-09-15T22:54:16Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]],['name' => 'Ars Technica','id' => 'FEED:3','bare_id' => 3,'icon' => 'feed-icons/3.ico','error' => 'argh','param' => '2016-05-23T06:40:02Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]],['name' => 'Uncategorized','id' => 'CAT:0','bare_id' => 0,'type' => 'category','auxcounter' => 0,'unread' => 0,'child_unread' => 0,'checkbox' => false,'parent_id' => null,'param' => '(1 feed)','items' => [['name' => 'Eurogamer','id' => 'FEED:6','bare_id' => 6,'icon' => 'feed-icons/6.ico','error' => '','param' => '2010-02-12T20:08:47Z','unread' => 0,'auxcounter' => 0,'checkbox' => false]]]]]];
$this->assertMessage($this->respGood($exp), $this->req($in[1]));
+ $this->dbMock->articleCount->twice()->calledWith($this->userId, $this->equalTo((new Context)->hidden(false)->unread(true)->modifiedSince(Date::sub("PT24H", self::NOW))));
}
- public function testMarkFeedsAsRead(): void {
- $in1 = [
- // no-ops
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx"],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 0],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1, 'is_cat' => true],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3, 'is_cat' => true],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'is_cat' => true],
- ];
- $in2 = [
- // simple contexts
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'is_cat' => true],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => 0, 'is_cat' => true],
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2, 'is_cat' => true],
- ];
- $in3 = [
- // this one has a tricky time-based context
- ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3],
- ];
- \Phake::when(Arsse::$db)->articleMark->thenThrow(new ExceptionInput("typeViolation"));
- $exp = $this->respGood(['status' => "OK"]);
- // verify the above are in fact no-ops
- for ($a = 0; $a < sizeof($in1); $a++) {
- $this->assertMessage($exp, $this->req($in1[$a]), "Test $a failed");
+ /** @dataProvider provideMassMarkings */
+ public function testMarkFeedsAsRead(array $in, ?Context $c): void {
+ $base = ['op' => "catchupFeed", 'sid' => "PriestsOfSyrinx"];
+ $in = array_merge($base, $in);
+ $this->dbMock->articleMark->throws(new ExceptionInput("typeViolation"));
+ // create a mock-current time
+ $this->objMock->get->with(\DateTimeImmutable::class)->returns(new \DateTimeImmutable(self::NOW));
+ // TT-RSS always responds the same regardless of success or failure
+ $this->assertMessage($this->respGood(['status' => "OK"]), $this->req($in));
+ if (isset($c)) {
+ $this->dbMock->articleMark->calledWith($this->userId, ['read' => true], $this->equalTo($c));
+ } else {
+ $this->dbMock->articleMark->never()->called();
}
- \Phake::verify(Arsse::$db, \Phake::times(0))->articleMark;
- // verify the simple contexts
- for ($a = 0; $a < sizeof($in2); $a++) {
- $this->assertMessage($exp, $this->req($in2[$a]), "Test $a failed");
- }
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], new Context);
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->starred(true));
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->label(1088));
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->subscription(2112));
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->folder(42));
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->folderShallow(0));
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->labelled(true));
- // verify the time-based mock
- $t = Date::sub("PT24H");
- for ($a = 0; $a < sizeof($in3); $a++) {
- $this->assertMessage($exp, $this->req($in3[$a]), "Test $a failed");
- }
- \Phake::verify(Arsse::$db)->articleMark($this->anything(), ['read' => true], $this->equalTo((new Context)->modifiedSince($t), 2)); // within two seconds
}
- public function testRetrieveFeedList(): void {
- $in1 = [
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx"],
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => -1],
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => -1, 'unread_only' => true],
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => -2],
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => -2, 'unread_only' => true],
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => -3],
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => -3, 'unread_only' => true],
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => -4],
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => -4, 'unread_only' => true],
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 6],
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 6, 'limit' => 1],
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 6, 'limit' => 1, 'offset' => 1],
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 1],
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 1, 'include_nested' => true],
- ];
- $in2 = [
- // these should all return an empty list
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 0, 'unread_only' => true],
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 2112],
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 2112, 'include_nested' => true],
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 6, 'limit' => -42],
- ['op' => "getFeeds", 'sid' => "PriestsOfSyrinx", 'cat_id' => 6, 'offset' => 2],
+ public function provideMassMarkings(): iterable {
+ $c = (new Context)->hidden(false);
+ return [
+ [[], null],
+ [['feed_id' => 0], null],
+ [['feed_id' => 0, 'is_cat' => true], (clone $c)->folderShallow(0)],
+ [['feed_id' => 0, 'is_cat' => true, 'mode' => "bogus"], (clone $c)->folderShallow(0)],
+ [['feed_id' => -1], (clone $c)->starred(true)],
+ [['feed_id' => -1, 'is_cat' => "t"], null],
+ [['feed_id' => -3], (clone $c)->modifiedSince(Date::sub("PT24H", self::NOW))],
+ [['feed_id' => -3, 'mode' => "1day"], (clone $c)->modifiedSince(Date::sub("PT24H", self::NOW))->notModifiedSince(Date::sub("PT24H", self::NOW))], // this is a nonsense query, but it's what TT-RSS appearsto do
+ [['feed_id' => -3, 'is_cat' => true], null],
+ [['feed_id' => -2], null],
+ [['feed_id' => -2, 'is_cat' => true], (clone $c)->labelled(true)],
+ [['feed_id' => -2, 'is_cat' => true, 'mode' => "all"], (clone $c)->labelled(true)],
+ [['feed_id' => -4], $c],
+ [['feed_id' => -4, 'is_cat' => true], null],
+ [['feed_id' => -6, 'is_cat' => "f"], null],
+ [['feed_id' => -2112], (clone $c)->label(1088)],
+ [['feed_id' => 42, 'is_cat' => true], (clone $c)->folder(42)],
+ [['feed_id' => 42, 'is_cat' => true, 'mode' => "1week"], (clone $c)->folder(42)->notModifiedSince(Date::sub("P1W", self::NOW))],
+ [['feed_id' => 2112], (clone $c)->subscription(2112)],
+ [['feed_id' => 2112, 'mode' => "2week"], (clone $c)->subscription(2112)->notModifiedSince(Date::sub("P2W", self::NOW))],
];
+ }
+
+ /** @dataProvider provideFeedListings */
+ public function testRetrieveFeedList(array $in, ResponseInterface $exp): void {
+ $in = array_merge(['op' => "getFeeds", 'sid' => "PriestsOfSyrinx"], $in);
// statistical mocks
- \Phake::when(Arsse::$db)->articleStarred($this->anything())->thenReturn($this->v($this->starred));
- \Phake::when(Arsse::$db)->articleCount($this->anything(), $this->equalTo((new Context)->unread(true)->modifiedSince(Date::sub("PT24H")), 2))->thenReturn(7);
- \Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(35);
+ $this->dbMock->articleStarred->returns($this->v($this->starred));
+ $this->dbMock->articleCount->with("~", $this->equalTo((new Context)->unread(true)->hidden(false)->modifiedSince(Date::sub("PT24H", self::NOW))))->returns(7);
+ $this->dbMock->articleCount->with("~", $this->equalTo((new Context)->unread(true)->hidden(false)))->returns(35);
// label mocks
- \Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->v($this->labels)));
- \Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->v($this->usedLabels)));
+ $this->dbMock->labelList->returns(new Result($this->v($this->labels)));
+ $this->dbMock->labelList->with("~", false)->returns(new Result($this->v($this->usedLabels)));
// subscription and folder list and unread count mocks
- \Phake::when(Arsse::$db)->folderList->thenThrow(new ExceptionInput("subjectMissing"));
- \Phake::when(Arsse::$db)->subscriptionList->thenThrow(new ExceptionInput("subjectMissing"));
- \Phake::when(Arsse::$db)->folderList($this->anything())->thenReturn(new Result($this->v($this->folders)));
- \Phake::when(Arsse::$db)->subscriptionList($this->anything(), null, true)->thenReturn(new Result($this->v($this->subscriptions)));
- \Phake::when(Arsse::$db)->subscriptionList($this->anything(), null, false)->thenReturn(new Result($this->v($this->filterSubs(null))));
- \Phake::when(Arsse::$db)->folderList($this->anything(), null)->thenReturn(new Result($this->v($this->folders)));
- \Phake::when(Arsse::$db)->folderList($this->anything(), null, false)->thenReturn(new Result($this->v($this->filterFolders(null))));
+ $this->dbMock->folderList->throws(new ExceptionInput("subjectMissing"));
+ $this->dbMock->subscriptionList->throws(new ExceptionInput("subjectMissing"));
+ $this->dbMock->folderList->with("~")->returns(new Result($this->v($this->folders)));
+ $this->dbMock->subscriptionList->with("~", null, true)->returns(new Result($this->v($this->subscriptions)));
+ $this->dbMock->subscriptionList->with("~", null, false)->returns(new Result($this->v($this->filterSubs(null))));
+ $this->dbMock->folderList->with("~", null)->returns(new Result($this->v($this->folders)));
+ $this->dbMock->folderList->with("~", null, false)->returns(new Result($this->v($this->filterFolders(null))));
foreach ($this->folders as $f) {
- \Phake::when(Arsse::$db)->folderList($this->anything(), $f['id'], false)->thenReturn(new Result($this->v($this->filterFolders($f['id']))));
- \Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true)->folder($f['id']))->thenReturn($this->reduceFolders($f['id']));
- \Phake::when(Arsse::$db)->subscriptionList($this->anything(), $f['id'], false)->thenReturn(new Result($this->v($this->filterSubs($f['id']))));
+ $this->dbMock->folderList->with("~", $f['id'], false)->returns(new Result($this->v($this->filterFolders($f['id']))));
+ $this->dbMock->articleCount->with("~", $this->equalTo((new Context)->unread(true)->hidden(false)->folder($f['id'])))->returns($this->reduceFolders($f['id']));
+ $this->dbMock->subscriptionList->with("~", $f['id'], false)->returns(new Result($this->v($this->filterSubs($f['id']))));
}
+ $this->assertMessage($exp, $this->req($in));
+ }
+
+ protected function filterFolders(int $id = null): array {
+ return array_filter($this->folders, function($value) use ($id) {
+ return $value['parent'] == $id;
+ });
+ }
+
+ protected function filterSubs(int $folder = null): array {
+ return array_filter($this->subscriptions, function($value) use ($folder) {
+ return $value['folder'] == $folder;
+ });
+ }
+
+ protected function reduceFolders(int $id = null): int {
+ $out = 0;
+ foreach ($this->filterFolders($id) as $f) {
+ $out += $this->reduceFolders($f['id']);
+ }
+ $out += array_reduce(array_filter($this->subscriptions, function($value) use ($id) {
+ return $value['folder'] == $id;
+ }), function($sum, $value) {
+ return $sum + $value['unread'];
+ }, 0);
+ return $out;
+ }
+
+ public function provideFeedListings(): iterable {
$exp = [
[
['id' => 6, 'title' => 'Eurogamer', 'unread' => 0, 'cat_id' => 0, 'feed_url' => " http://example.com/6", 'has_icon' => true, 'last_updated' => 1266005327, 'order_id' => 1],
@@ -1487,148 +1333,94 @@ LONG_STRING;
['id' => 3, 'title' => 'Ars Technica', 'unread' => 2, 'cat_id' => 1, 'feed_url' => " http://example.com/3", 'has_icon' => true, 'last_updated' => 1463985602, 'order_id' => 1],
],
];
- for ($a = 0; $a < sizeof($in1); $a++) {
- $this->assertMessage($this->respGood($exp[$a]), $this->req($in1[$a]), "Test $a failed");
- }
- for ($a = 0; $a < sizeof($in2); $a++) {
- $this->assertMessage($this->respGood([]), $this->req($in2[$a]), "Test $a failed");
- }
- }
-
- protected function filterFolders(int $id = null): array {
- return array_filter($this->folders, function($value) use ($id) {
- return $value['parent'] == $id;
- });
- }
-
- protected function filterSubs(int $folder = null): array {
- return array_filter($this->subscriptions, function($value) use ($folder) {
- return $value['folder'] == $folder;
- });
- }
-
- protected function reduceFolders(int $id = null): int {
- $out = 0;
- foreach ($this->filterFolders($id) as $f) {
- $out += $this->reduceFolders($f['id']);
- }
- $out += array_reduce(array_filter($this->subscriptions, function($value) use ($id) {
- return $value['folder'] == $id;
- }), function($sum, $value) {
- return $sum + $value['unread'];
- }, 0);
- return $out;
- }
-
- public function testChangeArticles(): void {
- $in = [
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx"],
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1"],
-
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 0],
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 0, 'mode' => 0],
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 0, 'mode' => 1],
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 0, 'mode' => 2],
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 0, 'mode' => 3], // invalid mode
-
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 1], // Published feed' no-op
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 1, 'mode' => 0],
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 1, 'mode' => 1],
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 1, 'mode' => 2],
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 1, 'mode' => 3], // invalid mode
-
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 2],
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 2, 'mode' => 0],
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 2, 'mode' => 1],
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 2, 'mode' => 2],
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 2, 'mode' => 3], // invalid mode
-
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 3],
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 3, 'mode' => 0],
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 3, 'mode' => 1],
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 3, 'mode' => 2],
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 3, 'mode' => 3], // invalid mode
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 3, 'data' => "eh"],
-
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 4], // invalid field
- ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "0, -1", 'field' => 3], // no valid IDs
+ return [
+ [[], $this->respGood($exp[0])],
+ [['cat_id' => -1], $this->respGood($exp[1])],
+ [['cat_id' => -1, 'unread_only' => true], $this->respGood($exp[2])],
+ [['cat_id' => -2], $this->respGood($exp[3])],
+ [['cat_id' => -2, 'unread_only' => true], $this->respGood($exp[4])],
+ [['cat_id' => -3], $this->respGood($exp[5])],
+ [['cat_id' => -3, 'unread_only' => true], $this->respGood($exp[6])],
+ [['cat_id' => -4], $this->respGood($exp[7])],
+ [['cat_id' => -4, 'unread_only' => true], $this->respGood($exp[8])],
+ [['cat_id' => 6], $this->respGood($exp[9])],
+ [['cat_id' => 6, 'limit' => 1], $this->respGood($exp[10])],
+ [['cat_id' => 6, 'limit' => 1, 'offset' => 1], $this->respGood($exp[11])],
+ [['cat_id' => 1], $this->respGood($exp[12])],
+ [['cat_id' => 1, 'include_nested' => true], $this->respGood($exp[13])],
+ [['cat_id' => 0, 'unread_only' => true], $this->respGood([])],
+ [['cat_id' => 2112], $this->respGood([])],
+ [['cat_id' => 2112, 'include_nested' => true], $this->respGood([])],
+ [['cat_id' => 6, 'limit' => -42], $this->respGood([])],
+ [['cat_id' => 6, 'offset' => 2], $this->respGood([])],
];
- \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([42, 2112])->starred(true), $this->anything())->thenReturn(new Result($this->v([['id' => 42]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([42, 2112])->starred(false), $this->anything())->thenReturn(new Result($this->v([['id' => 2112]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([42, 2112])->unread(true), $this->anything())->thenReturn(new Result($this->v([['id' => 42]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([42, 2112])->unread(false), $this->anything())->thenReturn(new Result($this->v([['id' => 2112]])));
- \Phake::when(Arsse::$db)->articleMark->thenReturn(1);
- \Phake::when(Arsse::$db)->articleMark($this->anything(), ['starred' => false], (new Context)->articles([42, 2112]))->thenReturn(2);
- \Phake::when(Arsse::$db)->articleMark($this->anything(), ['starred' => true], (new Context)->articles([42, 2112]))->thenReturn(4);
- \Phake::when(Arsse::$db)->articleMark($this->anything(), ['starred' => false], (new Context)->articles([42]))->thenReturn(8);
- \Phake::when(Arsse::$db)->articleMark($this->anything(), ['starred' => true], (new Context)->articles([2112]))->thenReturn(16);
- \Phake::when(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->articles([42, 2112]))->thenReturn(32); // false is read for TT-RSS
- \Phake::when(Arsse::$db)->articleMark($this->anything(), ['read' => false], (new Context)->articles([42, 2112]))->thenReturn(64);
- \Phake::when(Arsse::$db)->articleMark($this->anything(), ['read' => true], (new Context)->articles([42]))->thenReturn(128);
- \Phake::when(Arsse::$db)->articleMark($this->anything(), ['read' => false], (new Context)->articles([2112]))->thenReturn(256);
- \Phake::when(Arsse::$db)->articleMark($this->anything(), ['note' => ""], (new Context)->articles([42, 2112]))->thenReturn(512);
- \Phake::when(Arsse::$db)->articleMark($this->anything(), ['note' => "eh"], (new Context)->articles([42, 2112]))->thenReturn(1024);
- $out = [
- $this->respErr("INCORRECT_USAGE"),
- $this->respGood(['status' => "OK", 'updated' => 2]),
-
- $this->respGood(['status' => "OK", 'updated' => 2]),
- $this->respGood(['status' => "OK", 'updated' => 2]),
- $this->respGood(['status' => "OK", 'updated' => 4]),
- $this->respGood(['status' => "OK", 'updated' => 24]),
- $this->respErr("INCORRECT_USAGE"),
-
- $this->respGood(['status' => "OK", 'updated' => 0]),
- $this->respGood(['status' => "OK", 'updated' => 0]),
- $this->respGood(['status' => "OK", 'updated' => 0]),
- $this->respGood(['status' => "OK", 'updated' => 0]),
- $this->respErr("INCORRECT_USAGE"),
-
- $this->respGood(['status' => "OK", 'updated' => 32]),
- $this->respGood(['status' => "OK", 'updated' => 32]),
- $this->respGood(['status' => "OK", 'updated' => 64]),
- $this->respGood(['status' => "OK", 'updated' => 384]),
- $this->respErr("INCORRECT_USAGE"),
-
- $this->respGood(['status' => "OK", 'updated' => 512]),
- $this->respGood(['status' => "OK", 'updated' => 512]),
- $this->respGood(['status' => "OK", 'updated' => 512]),
- $this->respGood(['status' => "OK", 'updated' => 512]),
- $this->respGood(['status' => "OK", 'updated' => 512]),
- $this->respGood(['status' => "OK", 'updated' => 1024]),
-
- $this->respErr("INCORRECT_USAGE"),
- $this->respErr("INCORRECT_USAGE"),
- ];
- for ($a = 0; $a < sizeof($in); $a++) {
- $this->assertMessage($out[$a], $this->req($in[$a]), "Test $a failed");
- }
}
- public function testListArticles(): void {
- $in = [
- // error conditions
- ['op' => "getArticle", 'sid' => "PriestsOfSyrinx"],
- ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => 0],
- ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => -1],
- ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => "0,-1"],
- // acceptable input
- ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => "101,102"],
- ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => "101"],
- ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => "102"],
+ /** @dataProvider provideArticleChanges */
+ public function testChangeArticles(array $in, ResponseInterface $exp): void {
+ $in = array_merge(['op' => "updateArticle", 'sid' => "PriestsOfSyrinx"], $in);
+ $this->dbMock->articleMark->returns(1);
+ $this->dbMock->articleMark->with($this->userId, ['starred' => false], $this->equalTo((new Context)->articles([42, 2112])))->returns(2);
+ $this->dbMock->articleMark->with($this->userId, ['starred' => true], $this->equalTo((new Context)->articles([42, 2112])))->returns(4);
+ $this->dbMock->articleMark->with($this->userId, ['starred' => false], $this->equalTo((new Context)->articles([42])))->returns(8);
+ $this->dbMock->articleMark->with($this->userId, ['starred' => true], $this->equalTo((new Context)->articles([2112])))->returns(16);
+ $this->dbMock->articleMark->with($this->userId, ['read' => true], $this->equalTo((new Context)->articles([42, 2112])))->returns(32); // false is read for TT-RSS
+ $this->dbMock->articleMark->with($this->userId, ['read' => false], $this->equalTo((new Context)->articles([42, 2112])))->returns(64);
+ $this->dbMock->articleMark->with($this->userId, ['read' => true], $this->equalTo((new Context)->articles([42])))->returns(128);
+ $this->dbMock->articleMark->with($this->userId, ['read' => false], $this->equalTo((new Context)->articles([2112])))->returns(256);
+ $this->dbMock->articleMark->with($this->userId, ['note' => ""], $this->equalTo((new Context)->articles([42, 2112])))->returns(512);
+ $this->dbMock->articleMark->with($this->userId, ['note' => "eh"], $this->equalTo((new Context)->articles([42, 2112])))->returns(1024);
+ $this->dbMock->articleList->with($this->userId, $this->equalTo((new Context)->articles([42, 2112])->starred(true)), "~")->returns(new Result($this->v([['id' => 42]])));
+ $this->dbMock->articleList->with($this->userId, $this->equalTo((new Context)->articles([42, 2112])->starred(false)), "~")->returns(new Result($this->v([['id' => 2112]])));
+ $this->dbMock->articleList->with($this->userId, $this->equalTo((new Context)->articles([42, 2112])->unread(true)), "~")->returns(new Result($this->v([['id' => 42]])));
+ $this->dbMock->articleList->with($this->userId, $this->equalTo((new Context)->articles([42, 2112])->unread(false)), "~")->returns(new Result($this->v([['id' => 2112]])));
+ $this->assertMessage($exp, $this->req($in));
+ }
+
+ public function provideArticleChanges(): iterable {
+ return [
+ [[], $this->respErr("INCORRECT_USAGE")],
+ [['article_ids' => "42, 2112, -1"], $this->respGood(['status' => "OK", 'updated' => 2])],
+ [['article_ids' => "42, 2112, -1", 'field' => 0], $this->respGood(['status' => "OK", 'updated' => 2])],
+ [['article_ids' => "42, 2112, -1", 'field' => 0, 'mode' => 0], $this->respGood(['status' => "OK", 'updated' => 2])],
+ [['article_ids' => "42, 2112, -1", 'field' => 0, 'mode' => 1], $this->respGood(['status' => "OK", 'updated' => 4])],
+ [['article_ids' => "42, 2112, -1", 'field' => 0, 'mode' => 2], $this->respGood(['status' => "OK", 'updated' => 24])],
+ [['article_ids' => "42, 2112, -1", 'field' => 0, 'mode' => 3], $this->respErr("INCORRECT_USAGE")],
+ [['article_ids' => "42, 2112, -1", 'field' => 1], $this->respGood(['status' => "OK", 'updated' => 0])],
+ [['article_ids' => "42, 2112, -1", 'field' => 1, 'mode' => 0], $this->respGood(['status' => "OK", 'updated' => 0])],
+ [['article_ids' => "42, 2112, -1", 'field' => 1, 'mode' => 1], $this->respGood(['status' => "OK", 'updated' => 0])],
+ [['article_ids' => "42, 2112, -1", 'field' => 1, 'mode' => 2], $this->respGood(['status' => "OK", 'updated' => 0])],
+ [['article_ids' => "42, 2112, -1", 'field' => 1, 'mode' => 3], $this->respErr("INCORRECT_USAGE")],
+ [['article_ids' => "42, 2112, -1", 'field' => 2], $this->respGood(['status' => "OK", 'updated' => 32])],
+ [['article_ids' => "42, 2112, -1", 'field' => 2, 'mode' => 0], $this->respGood(['status' => "OK", 'updated' => 32])],
+ [['article_ids' => "42, 2112, -1", 'field' => 2, 'mode' => 1], $this->respGood(['status' => "OK", 'updated' => 64])],
+ [['article_ids' => "42, 2112, -1", 'field' => 2, 'mode' => 2], $this->respGood(['status' => "OK", 'updated' => 384])],
+ [['article_ids' => "42, 2112, -1", 'field' => 2, 'mode' => 3], $this->respErr("INCORRECT_USAGE")],
+ [['article_ids' => "42, 2112, -1", 'field' => 3], $this->respGood(['status' => "OK", 'updated' => 512])],
+ [['article_ids' => "42, 2112, -1", 'field' => 3, 'mode' => 0], $this->respGood(['status' => "OK", 'updated' => 512])],
+ [['article_ids' => "42, 2112, -1", 'field' => 3, 'mode' => 1], $this->respGood(['status' => "OK", 'updated' => 512])],
+ [['article_ids' => "42, 2112, -1", 'field' => 3, 'mode' => 2], $this->respGood(['status' => "OK", 'updated' => 512])],
+ [['article_ids' => "42, 2112, -1", 'field' => 3, 'mode' => 3], $this->respGood(['status' => "OK", 'updated' => 512])],
+ [['article_ids' => "42, 2112, -1", 'field' => 3, 'data' => "eh"], $this->respGood(['status' => "OK", 'updated' => 1024])],
+ [['article_ids' => "42, 2112, -1", 'field' => 4], $this->respErr("INCORRECT_USAGE")],
+ [['article_ids' => "0, -1", 'field' => 3], $this->respErr("INCORRECT_USAGE")],
];
- \Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->v($this->labels)));
- \Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->v($this->usedLabels)));
- \Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 101)->thenReturn([]);
- \Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 102)->thenReturn($this->v([1,3]));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([101, 102]), $this->anything())->thenReturn(new Result($this->v($this->articles)));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([101]), $this->anything())->thenReturn(new Result($this->v([$this->articles[0]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([102]), $this->anything())->thenReturn(new Result($this->v([$this->articles[1]])));
- $exp = $this->respErr("INCORRECT_USAGE");
- $this->assertMessage($exp, $this->req($in[0]));
- $this->assertMessage($exp, $this->req($in[1]));
- $this->assertMessage($exp, $this->req($in[2]));
- $this->assertMessage($exp, $this->req($in[3]));
+ }
+
+ /** @dataProvider provideArticleListings */
+ public function testListArticles(array $in, ResponseInterface $exp): void {
+ $in = array_merge(['op' => "getArticle", 'sid' => "PriestsOfSyrinx"], $in);
+ $this->dbMock->labelList->with("~")->returns(new Result($this->v($this->labels)));
+ $this->dbMock->labelList->with("~", false)->returns(new Result($this->v($this->usedLabels)));
+ $this->dbMock->articleLabelsGet->with("~", 101)->returns([]);
+ $this->dbMock->articleLabelsGet->with("~", 102)->returns($this->v([1,3]));
+ $this->dbMock->articleList->with("~", $this->equalTo((new Context)->articles([101, 102])), "~")->returns(new Result($this->v($this->articles)));
+ $this->dbMock->articleList->with("~", $this->equalTo((new Context)->articles([101])), "~")->returns(new Result($this->v([$this->articles[0]])));
+ $this->dbMock->articleList->with("~", $this->equalTo((new Context)->articles([102])), "~")->returns(new Result($this->v([$this->articles[1]])));
+ $this->assertMessage($exp, $this->req($in));
+ }
+
+ public function provideArticleListings(): iterable {
$exp = [
[
'id' => "101",
@@ -1685,215 +1477,106 @@ LONG_STRING;
'content' => 'Article content 2
',
],
];
- $this->assertMessage($this->respGood($exp), $this->req($in[4]));
- $this->assertMessage($this->respGood([$exp[0]]), $this->req($in[5]));
- $this->assertMessage($this->respGood([$exp[1]]), $this->req($in[6]));
- // test the special case when labels are not used
- \Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result([]));
- \Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result([]));
- $this->assertMessage($this->respGood([$exp[0]]), $this->req($in[5]));
+ return [
+ [[], $this->respErr("INCORRECT_USAGE")],
+ [['article_id' => 0], $this->respErr("INCORRECT_USAGE")],
+ [['article_id' => -1], $this->respErr("INCORRECT_USAGE")],
+ [['article_id' => "0,-1"], $this->respErr("INCORRECT_USAGE")],
+ [['article_id' => "101,102"], $this->respGood($exp)],
+ [['article_id' => "101"], $this->respGood([$exp[0]])],
+ [['article_id' => "102"], $this->respGood([$exp[1]])],
+ ];
}
- public function testRetrieveCompactHeadlines(): void {
- $in1 = [
- // erroneous input
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx"],
- // empty results
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 0],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2, 'is_cat' => true], // is_cat is not used in getCompactHeadlines
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'view_mode' => "published"],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6, 'view_mode' => "unread"],
- // non-empty results
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'view_mode' => "adaptive"],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112, 'view_mode' => "adaptive"],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112, 'view_mode' => "unread"],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "marked"],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "has_note"],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'limit' => 5],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'skip' => 2],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'limit' => 5, 'skip' => 2],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'since_id' => 47],
- ];
- $in2 = [
- // time-based contexts, handled separately
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6, 'view_mode' => "adaptive"],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3],
- ['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3, 'view_mode' => "marked"],
- ];
- \Phake::when(Arsse::$db)->articleList->thenReturn(new Result($this->v([['id' => 0]])));
- \Phake::when(Arsse::$db)->articleCount->thenReturn(0);
- \Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1);
- $c = (new Context);
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), ["id"], ["edited_date desc"])->thenThrow(new ExceptionInput("subjectMissing"));
- \Phake::when(Arsse::$db)->articleList($this->anything(), $c, ["id"], ["edited_date desc"])->thenReturn(new Result($this->v($this->articles)));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), ["id"], ["marked_date desc"])->thenReturn(new Result($this->v([['id' => 1]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 2]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 3]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 4]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 5]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 6]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 7]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 8]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 9]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 10]])));
- $out1 = [
- $this->respErr("INCORRECT_USAGE"),
- $this->respGood([]),
- $this->respGood([]),
- $this->respGood([]),
- $this->respGood([]),
- $this->respGood([]),
- $this->respGood([]),
- $this->respGood([['id' => 101],['id' => 102]]),
- $this->respGood([['id' => 1]]),
- $this->respGood([['id' => 2]]),
- $this->respGood([['id' => 3]]),
- $this->respGood([['id' => 2]]), // the result is 2 rather than 4 because there are no unread, so the unread context is not used
- $this->respGood([['id' => 4]]),
- $this->respGood([['id' => 5]]),
- $this->respGood([['id' => 6]]),
- $this->respGood([['id' => 7]]),
- $this->respGood([['id' => 8]]),
- $this->respGood([['id' => 9]]),
- $this->respGood([['id' => 10]]),
- ];
- $out2 = [
- $this->respGood([['id' => 1001]]),
- $this->respGood([['id' => 1001]]),
- $this->respGood([['id' => 1002]]),
- $this->respGood([['id' => 1003]]),
- ];
- for ($a = 0; $a < sizeof($in1); $a++) {
- $this->assertMessage($out1[$a], $this->req($in1[$a]), "Test $a failed");
- }
- for ($a = 0; $a < sizeof($in2); $a++) {
- \Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(false)->markedSince(Date::sub("PT24H")), 2), ["id"], ["marked_date desc"])->thenReturn(new Result($this->v([['id' => 1001]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), 2), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 1002]])));
- \Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), 2), ["id"], ["edited_date desc"])->thenReturn(new Result($this->v([['id' => 1003]])));
- $this->assertMessage($out2[$a], $this->req($in2[$a]), "Test $a failed");
+ /** @dataProvider provideHeadlines */
+ public function testRetrieveHeadlines(bool $full, array $in, $out, Context $c, array $fields, array $order, ResponseInterface $exp): void {
+ $base = ['op' => $full ? "getHeadlines" : "getCompactHeadlines", 'sid' => "PriestsOfSyrinx"];
+ $in = array_merge($base, $in);
+ $action = ($out instanceof \Exception) ? "throws" : "returns";
+ $this->objMock->get->with(\DateTimeImmutable::class)->returns(new \DateTimeImmutable(self::NOW));
+ $this->dbMock->labelList->returns(new Result($this->v($this->labels)));
+ $this->dbMock->labelList->with("~", false)->returns(new Result($this->v($this->usedLabels)));
+ $this->dbMock->articleLabelsGet->returns([]);
+ $this->dbMock->articleLabelsGet->with("~", 2112)->returns($this->v([1,3]));
+ $this->dbMock->articleCategoriesGet->returns([]);
+ $this->dbMock->articleCategoriesGet->with("~", 2112)->returns(["Boring","Illogical"]);
+ $this->dbMock->articleCount->returns(2);
+ $this->dbMock->articleList->$action($out);
+ $this->assertMessage($exp, $this->req($in));
+ if ($out) {
+ $this->dbMock->articleList->calledWith($this->userId, $this->equalTo($c), $fields, $order);
+ } else {
+ $this->dbMock->articleList->never()->called();
}
}
- public function testRetrieveFullHeadlines(): void {
- $in1 = [
- // empty results
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 0],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1, 'is_cat' => true],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'view_mode' => "published"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6, 'view_mode' => "unread"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "unread", 'search' => "unread:false"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'search' => "pub:true"],
+ public function provideHeadlines(): iterable {
+ $t = Date::normalize(self::NOW);
+ $c = (new Context)->hidden(false)->limit(200);
+ $out = $this->generateHeadlines(47);
+ $gone = new ExceptionInput("idMissing");
+ $comp = new Result($this->v([['id' => 47], ['id' => 2112]]));
+ $expFull = $this->outputHeadlines(47);
+ $expComp = $this->respGood([['id' => 47], ['id' => 2112]]);
+ $fields = ["id", "guid", "title", "author", "url", "unread", "starred", "edited_date", "published_date", "subscription", "subscription_title", "note"];
+ $sort = ["edited_date desc"];
+ return [
+ [true, [], null, $c, [], [], $this->respErr("INCORRECT_USAGE")],
+ [true, ['feed_id' => 0], null, $c, [], [], $this->respGood([])],
+ [true, ['feed_id' => -1], $out, (clone $c)->starred(true), $fields, ["marked_date desc"], $expFull],
+ [true, ['feed_id' => -2], null, $c, [], [], $this->respGood([])],
+ [true, ['feed_id' => -4], $out, $c, $fields, $sort, $expFull],
+ [true, ['feed_id' => 2112], $gone, (clone $c)->subscription(2112), $fields, $sort, $this->respGood([])],
+ [true, ['feed_id' => -2112], $out, (clone $c)->label(1088), $fields, $sort, $expFull],
+ [true, ['feed_id' => -4, 'view_mode' => "adaptive"], $out, (clone $c)->unread(true), $fields, $sort, $expFull],
+ [true, ['feed_id' => -4, 'view_mode' => "published"], null, $c, [], [], $this->respGood([])],
+ [true, ['feed_id' => -2112, 'view_mode' => "adaptive"], $out, (clone $c)->label(1088)->unread(true), $fields, $sort, $expFull],
+ [true, ['feed_id' => -2112, 'view_mode' => "unread"], $out, (clone $c)->label(1088)->unread(true), $fields, $sort, $expFull],
+ [true, ['feed_id' => 42, 'view_mode' => "marked"], $out, (clone $c)->subscription(42)->starred(true), $fields, $sort, $expFull],
+ [true, ['feed_id' => 42, 'view_mode' => "has_note"], $out, (clone $c)->subscription(42)->annotated(true), $fields, $sort, $expFull],
+ [true, ['feed_id' => 42, 'view_mode' => "unread", 'search' => "unread:false"], null, $c, [], [], $this->respGood([])],
+ [true, ['feed_id' => 42, 'search' => "pub:true"], null, $c, [], [], $this->respGood([])],
+ [true, ['feed_id' => -4, 'limit' => 5], $out, (clone $c)->limit(5), $fields, $sort, $expFull],
+ [true, ['feed_id' => -4, 'skip' => 2], $out, (clone $c)->offset(2), $fields, $sort, $expFull],
+ [true, ['feed_id' => -4, 'limit' => 5, 'skip' => 2], $out, (clone $c)->limit(5)->offset(2), $fields, $sort, $expFull],
+ [true, ['feed_id' => -4, 'since_id' => 47], $out, (clone $c)->oldestArticle(48), $fields, $sort, $expFull],
+ [true, ['feed_id' => -3, 'is_cat' => true], $out, $c, $fields, $sort, $expFull],
+ [true, ['feed_id' => -4, 'is_cat' => true], $out, $c, $fields, $sort, $expFull],
+ [true, ['feed_id' => -2, 'is_cat' => true], $out, (clone $c)->labelled(true), $fields, $sort, $expFull],
+ [true, ['feed_id' => -1, 'is_cat' => true], null, $c, [], [], $this->respGood([])],
+ [true, ['feed_id' => 0, 'is_cat' => true], $out, (clone $c)->folderShallow(0), $fields, $sort, $expFull],
+ [true, ['feed_id' => 0, 'is_cat' => true, 'include_nested' => true], $out, (clone $c)->folderShallow(0), $fields, $sort, $expFull],
+ [true, ['feed_id' => 42, 'is_cat' => true], $out, (clone $c)->folderShallow(42), $fields, $sort, $expFull],
+ [true, ['feed_id' => 42, 'is_cat' => true, 'include_nested' => true], $out, (clone $c)->folder(42), $fields, $sort, $expFull],
+ [true, ['feed_id' => -4, 'order_by' => "feed_dates"], $out, $c, $fields, $sort, $expFull],
+ [true, ['feed_id' => -4, 'order_by' => "date_reverse"], $out, $c, $fields, ["edited_date"], $expFull],
+ [true, ['feed_id' => 42, 'search' => "interesting"], $out, (clone $c)->subscription(42)->searchTerms(["interesting"]), $fields, $sort, $expFull],
+ [true, ['feed_id' => -6], $out, (clone $c)->unread(false)->markedSince(Date::sub("PT24H", $t)), $fields, ["marked_date desc"], $expFull],
+ [true, ['feed_id' => -6, 'view_mode' => "unread"], null, $c, $fields, $sort, $this->respGood([])],
+ [true, ['feed_id' => -3], $out, (clone $c)->unread(true)->modifiedSince(Date::sub("PT24H", $t)), $fields, $sort, $expFull],
+ [true, ['feed_id' => -3, 'view_mode' => "marked"], $out, (clone $c)->unread(true)->starred(true)->modifiedSince(Date::sub("PT24H", $t)), $fields, $sort, $expFull],
+ [false, [], null, (clone $c)->limit(null), [], [], $this->respErr("INCORRECT_USAGE")],
+ [false, ['feed_id' => 0], null, (clone $c)->limit(null), [], [], $this->respGood([])],
+ [false, ['feed_id' => -1], $comp, (clone $c)->limit(null)->starred(true), ["id"], ["marked_date desc"], $expComp],
+ [false, ['feed_id' => -2], null, (clone $c)->limit(null), [], [], $this->respGood([])],
+ [false, ['feed_id' => -4], $comp, (clone $c)->limit(null), ["id"], $sort, $expComp],
+ [false, ['feed_id' => 2112], $gone, (clone $c)->limit(null)->subscription(2112), ["id"], $sort, $this->respGood([])],
+ [false, ['feed_id' => -2112], $comp, (clone $c)->limit(null)->label(1088), ["id"], $sort, $expComp],
+ [false, ['feed_id' => -4, 'view_mode' => "adaptive"], $comp, (clone $c)->limit(null)->unread(true), ["id"], $sort, $expComp],
+ [false, ['feed_id' => -4, 'view_mode' => "published"], null, (clone $c)->limit(null), [], [], $this->respGood([])],
+ [false, ['feed_id' => -2112, 'view_mode' => "adaptive"], $comp, (clone $c)->limit(null)->label(1088)->unread(true), ["id"], $sort, $expComp],
+ [false, ['feed_id' => -2112, 'view_mode' => "unread"], $comp, (clone $c)->limit(null)->label(1088)->unread(true), ["id"], $sort, $expComp],
+ [false, ['feed_id' => 42, 'view_mode' => "marked"], $comp, (clone $c)->limit(null)->subscription(42)->starred(true), ["id"], $sort, $expComp],
+ [false, ['feed_id' => 42, 'view_mode' => "has_note"], $comp, (clone $c)->limit(null)->subscription(42)->annotated(true), ["id"], $sort, $expComp],
+ [false, ['feed_id' => -4, 'limit' => 5], $comp, (clone $c)->limit(5), ["id"], $sort, $expComp],
+ [false, ['feed_id' => -4, 'skip' => 2], $comp, (clone $c)->limit(null)->offset(2), ["id"], $sort, $expComp],
+ [false, ['feed_id' => -4, 'limit' => 5, 'skip' => 2], $comp, (clone $c)->limit(5)->offset(2), ["id"], $sort, $expComp],
+ [false, ['feed_id' => -4, 'since_id' => 47], $comp, (clone $c)->limit(null)->oldestArticle(48), ["id"], $sort, $expComp],
+ [false, ['feed_id' => -6], $comp, (clone $c)->limit(null)->unread(false)->markedSince(Date::sub("PT24H", $t)), ["id"], ["marked_date desc"], $expComp],
+ [false, ['feed_id' => -6, 'view_mode' => "unread"], null, (clone $c)->limit(null), ["id"], $sort, $this->respGood([])],
+ [false, ['feed_id' => -3], $comp, (clone $c)->limit(null)->unread(true)->modifiedSince(Date::sub("PT24H", $t)), ["id"], $sort, $expComp],
+ [false, ['feed_id' => -3, 'view_mode' => "marked"], $comp, (clone $c)->limit(null)->unread(true)->starred(true)->modifiedSince(Date::sub("PT24H", $t)), ["id"], $sort, $expComp],
];
- $in2 = [
- // simple context tests
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'view_mode' => "adaptive"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112, 'view_mode' => "adaptive"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112, 'view_mode' => "unread"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "marked"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "has_note"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'limit' => 5],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'skip' => 2],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'limit' => 5, 'skip' => 2],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'since_id' => 47],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3, 'is_cat' => true],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'is_cat' => true],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2, 'is_cat' => true],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 0, 'is_cat' => true],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'is_cat' => true],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'is_cat' => true, 'include_nested' => true],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'order_by' => "feed_dates"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'order_by' => "date_reverse"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'search' => "interesting"],
- ];
- $in3 = [
- // time-based context tests
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6, 'view_mode' => "adaptive"],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3],
- ['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3, 'view_mode' => "marked"],
- ];
- \Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->v($this->labels)));
- \Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->v($this->usedLabels)));
- \Phake::when(Arsse::$db)->articleLabelsGet->thenReturn([]);
- \Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 2112)->thenReturn($this->v([1,3]));
- \Phake::when(Arsse::$db)->articleCategoriesGet->thenReturn([]);
- \Phake::when(Arsse::$db)->articleCategoriesGet($this->anything(), 2112)->thenReturn(["Boring","Illogical"]);
- \Phake::when(Arsse::$db)->articleList->thenReturn($this->generateHeadlines(0));
- \Phake::when(Arsse::$db)->articleCount->thenReturn(0);
- \Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1);
- $c = (new Context)->limit(200);
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(2112), $this->anything(), ["edited_date desc"])->thenThrow(new ExceptionInput("subjectMissing"));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->starred(true), $this->anything(), ["marked_date desc"])->thenReturn($this->generateHeadlines(1));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(2));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->unread(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(3));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->label(1088)->unread(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(4));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->starred(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(5));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->annotated(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(6));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(7));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->offset(2), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(8));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->limit(5)->offset(2), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(9));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->oldestArticle(48), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(10));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(11));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->labelled(true), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(12));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(0), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(13));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folderShallow(42), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(14));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->folder(42), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(15));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c), $this->anything(), ["edited_date"])->thenReturn($this->generateHeadlines(16));
- \Phake::when(Arsse::$db)->articleList($this->anything(), (clone $c)->subscription(42)->searchTerms(["interesting"]), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(17));
- $out2 = [
- $this->respErr("INCORRECT_USAGE"),
- $this->outputHeadlines(11),
- $this->outputHeadlines(1),
- $this->outputHeadlines(2),
- $this->outputHeadlines(3),
- $this->outputHeadlines(2), // the result is 2 rather than 4 because there are no unread, so the unread context is not used
- $this->outputHeadlines(4),
- $this->outputHeadlines(5),
- $this->outputHeadlines(6),
- $this->outputHeadlines(7),
- $this->outputHeadlines(8),
- $this->outputHeadlines(9),
- $this->outputHeadlines(10),
- $this->outputHeadlines(11),
- $this->outputHeadlines(11),
- $this->outputHeadlines(12),
- $this->outputHeadlines(13),
- $this->outputHeadlines(14),
- $this->outputHeadlines(15),
- $this->outputHeadlines(11), // defaulting sorting is not fully implemented
- $this->outputHeadlines(16),
- $this->outputHeadlines(17),
- ];
- $out3 = [
- $this->outputHeadlines(1001),
- $this->outputHeadlines(1001),
- $this->outputHeadlines(1002),
- $this->outputHeadlines(1003),
- ];
- for ($a = 0; $a < sizeof($in1); $a++) {
- $this->assertMessage($this->respGood([]), $this->req($in1[$a]), "Test $a failed");
- }
- for ($a = 0; $a < sizeof($in2); $a++) {
- $this->assertMessage($out2[$a], $this->req($in2[$a]), "Test $a failed");
- }
- for ($a = 0; $a < sizeof($in3); $a++) {
- \Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(false)->markedSince(Date::sub("PT24H")), 2), $this->anything(), ["marked_date desc"])->thenReturn($this->generateHeadlines(1001));
- \Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H")), 2), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(1002));
- \Phake::when(Arsse::$db)->articleList($this->anything(), $this->equalTo((clone $c)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), 2), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(1003));
- $this->assertMessage($out3[$a], $this->req($in3[$a]), "Test $a failed");
- }
}
public function testRetrieveFullHeadlinesCheckingExtraFields(): void {
@@ -1911,15 +1594,15 @@ LONG_STRING;
['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'skip' => 47, 'include_header' => true, 'order_by' => "date_reverse"],
['op' => "getHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'show_excerpt' => true],
];
- \Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->v($this->labels)));
- \Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->v($this->usedLabels)));
- \Phake::when(Arsse::$db)->articleLabelsGet->thenReturn([]);
- \Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 2112)->thenReturn($this->v([1,3]));
- \Phake::when(Arsse::$db)->articleCategoriesGet->thenReturn([]);
- \Phake::when(Arsse::$db)->articleCategoriesGet($this->anything(), 2112)->thenReturn(["Boring","Illogical"]);
- \Phake::when(Arsse::$db)->articleList->thenReturn($this->generateHeadlines(1));
- \Phake::when(Arsse::$db)->articleCount->thenReturn(0);
- \Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1);
+ $this->dbMock->labelList->with("~")->returns(new Result($this->v($this->labels)));
+ $this->dbMock->labelList->with("~", false)->returns(new Result($this->v($this->usedLabels)));
+ $this->dbMock->articleLabelsGet->returns([]);
+ $this->dbMock->articleLabelsGet->with("~", 2112)->returns($this->v([1,3]));
+ $this->dbMock->articleCategoriesGet->returns([]);
+ $this->dbMock->articleCategoriesGet->with("~", 2112)->returns(["Boring","Illogical"]);
+ $this->dbMock->articleList->returns($this->generateHeadlines(1));
+ $this->dbMock->articleCount->returns(0);
+ $this->dbMock->articleCount->with("~", $this->equalTo((new Context)->unread(true)->hidden(false)))->returns(1);
// sanity check; this makes sure extra fields are not included in default situations
$test = $this->req($in[0]);
$this->assertMessage($this->outputHeadlines(1), $test);
@@ -1970,7 +1653,7 @@ LONG_STRING;
]);
$this->assertMessage($exp, $test);
// test 'include_header' with an erroneous result
- \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(200)->subscription(2112), $this->anything(), ["edited_date desc"])->thenThrow(new ExceptionInput("subjectMissing"));
+ $this->dbMock->articleList->with("~", $this->equalTo((new Context)->limit(200)->subscription(2112)->hidden(false)), "~", ["edited_date desc"])->throws(new ExceptionInput("subjectMissing"));
$test = $this->req($in[6]);
$exp = $this->respGood([
['id' => 2112, 'is_cat' => false, 'first_id' => 0],
@@ -1985,7 +1668,7 @@ LONG_STRING;
]);
$this->assertMessage($exp, $test);
// test 'include_header' with skip
- \Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(1)->subscription(42), $this->anything(), ["edited_date desc"])->thenReturn($this->generateHeadlines(1867));
+ $this->dbMock->articleList->with("~", $this->equalTo((new Context)->limit(1)->subscription(42)->hidden(false)), "~", ["edited_date desc"])->returns($this->generateHeadlines(1867));
$test = $this->req($in[8]);
$exp = $this->respGood([
['id' => 42, 'is_cat' => false, 'first_id' => 1867],
diff --git a/tests/cases/REST/TinyTinyRSS/TestIcon.php b/tests/cases/REST/TinyTinyRSS/TestIcon.php
index 38dbd8fe..f541f504 100644
--- a/tests/cases/REST/TinyTinyRSS/TestIcon.php
+++ b/tests/cases/REST/TinyTinyRSS/TestIcon.php
@@ -9,6 +9,7 @@ namespace JKingWeb\Arsse\TestCase\REST\TinyTinyRSS;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Database;
+use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\REST\TinyTinyRSS\Icon;
use Psr\Http\Message\ResponseInterface;
use Laminas\Diactoros\Response\EmptyResponse as Response;
@@ -19,20 +20,16 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest {
protected $user = "john.doe@example.com";
public function setUp(): void {
- self::clearData();
+ parent::setUp();
self::setConf();
- // create a mock user manager
- Arsse::$user = \Phake::mock(User::class);
+ Arsse::$user = $this->mock(User::class)->get();
// create a mock database interface
- Arsse::$db = \Phake::mock(Database::class);
+ $this->dbMock = $this->mock(Database::class);
$this->h = new Icon();
}
- public function tearDown(): void {
- self::clearData();
- }
-
protected function req(string $target, string $method = "GET", string $user = null): ResponseInterface {
+ Arsse::$db = $this->dbMock->get();
$prefix = "/tt-rss/feed-icons/";
$url = $prefix.$target;
$req = $this->serverRequest($method, $url, $prefix, [], [], null, "", [], $user);
@@ -48,10 +45,11 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testRetrieveFavion(): void {
- \Phake::when(Arsse::$db)->subscriptionFavicon->thenReturn("");
- \Phake::when(Arsse::$db)->subscriptionFavicon(42, $this->anything())->thenReturn("http://example.com/favicon.ico");
- \Phake::when(Arsse::$db)->subscriptionFavicon(2112, $this->anything())->thenReturn("http://example.net/logo.png");
- \Phake::when(Arsse::$db)->subscriptionFavicon(1337, $this->anything())->thenReturn("http://example.org/icon.gif\r\nLocation: http://bad.example.com/");
+ $this->dbMock->subscriptionIcon->returns(['url' => null]);
+ $this->dbMock->subscriptionIcon->with($this->anything(), 1123, false)->throws(new ExceptionInput("subjectMissing"));
+ $this->dbMock->subscriptionIcon->with($this->anything(), 42, false)->returns(['url' => "http://example.com/favicon.ico"]);
+ $this->dbMock->subscriptionIcon->with($this->anything(), 2112, false)->returns(['url' => "http://example.net/logo.png"]);
+ $this->dbMock->subscriptionIcon->with($this->anything(), 1337, false)->returns(['url' => "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->assertMessage($exp, $this->req("42.ico"));
@@ -65,20 +63,21 @@ class TestIcon extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertMessage($exp, $this->req("ook"));
$this->assertMessage($exp, $this->req("47.ico"));
$this->assertMessage($exp, $this->req("2112.png"));
+ $this->assertMessage($exp, $this->req("1123.ico"));
// only GET is allowed
$exp = new Response(405, ['Allow' => "GET"]);
$this->assertMessage($exp, $this->req("2112.ico", "PUT"));
}
public function testRetrieveFavionWithHttpAuthentication(): void {
- $url = "http://example.org/icon.gif\r\nLocation: http://bad.example.com/";
- \Phake::when(Arsse::$db)->subscriptionFavicon->thenReturn("");
- \Phake::when(Arsse::$db)->subscriptionFavicon(42, $this->user)->thenReturn($url);
- \Phake::when(Arsse::$db)->subscriptionFavicon(2112, "jane.doe")->thenReturn($url);
- \Phake::when(Arsse::$db)->subscriptionFavicon(1337, $this->user)->thenReturn($url);
- \Phake::when(Arsse::$db)->subscriptionFavicon(42, null)->thenReturn($url);
- \Phake::when(Arsse::$db)->subscriptionFavicon(2112, null)->thenReturn($url);
- \Phake::when(Arsse::$db)->subscriptionFavicon(1337, null)->thenReturn($url);
+ $url = ['url' => "http://example.org/icon.gif\r\nLocation: http://bad.example.com/"];
+ $this->dbMock->subscriptionIcon->returns(['url' => null]);
+ $this->dbMock->subscriptionIcon->with($this->user, 42, false)->returns($url);
+ $this->dbMock->subscriptionIcon->with("jane.doe", 2112, false)->returns($url);
+ $this->dbMock->subscriptionIcon->with($this->user, 1337, false)->returns($url);
+ $this->dbMock->subscriptionIcon->with(null, 42, false)->returns($url);
+ $this->dbMock->subscriptionIcon->with(null, 2112, false)->returns($url);
+ $this->dbMock->subscriptionIcon->with(null, 1337, false)->returns($url);
// these requests should succeed
$exp = new Response(301, ['Location' => "http://example.org/icon.gif"]);
$this->assertMessage($exp, $this->req("42.ico"));
diff --git a/tests/cases/Service/TestDaemon.php b/tests/cases/Service/TestDaemon.php
new file mode 100644
index 00000000..b9116569
--- /dev/null
+++ b/tests/cases/Service/TestDaemon.php
@@ -0,0 +1,209 @@
+ [
+ 'create' => [],
+ 'read' => "cannot be read",
+ 'write' => "cannot be written to",
+ 'readwrite' => "can neither be read nor written to",
+ ],
+ 'ok' => [
+ 'dir' => [],
+ 'file' => "this file can be fully accessed",
+ ],
+ 'pid' => [
+ 'current' => "2112",
+ 'stale' => "42",
+ "empty" => "",
+ 'malformed1' => "02112",
+ 'malformed2' => "2112 ",
+ 'malformed3' => "2112\n",
+ 'bogus1' => "bogus",
+ 'bogus2' => " ",
+ 'bogus3' => "\n",
+ 'overlong' => "123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890",
+ 'locked' => "", // this file will be locked by the test
+ 'unreadable' => "", // this file will be chmodded by the test
+ 'unwritable' => "", // this file will be chmodded by the test
+ ],
+ ];
+
+ public function setUp(): void {
+ parent::setUp();
+ $this->daemon = $this->partialMock(Daemon::class);
+ }
+
+ /** @dataProvider providePathResolutions */
+ public function testResolveRelativePaths(string $path, $cwd, $exp): void {
+ // set up mock daemon class
+ $this->daemon->cwd->returns($cwd);
+ $daemon = $this->daemon->get();
+ // perform the test
+ $this->AssertSame($exp, $daemon->resolveRelativePath($path));
+ }
+
+ public function providePathResolutions(): iterable {
+ return [
+ ["/", "/home/me", "/"],
+ ["/.", "/home/me", "/"],
+ ["/..", "/home/me", "/"],
+ ["/run", "/home/me", "/run"],
+ ["/./run", "/home/me", "/run"],
+ ["/../run", "/home/me", "/run"],
+ ["/run/../run", "/home/me", "/run"],
+ ["/run/./run", "/home/me", "/run/run"],
+ ["run", "/home/me", "/home/me/run"],
+ ["run/..", "/home/me", "/home/me"],
+ [".", "/", "/"],
+ [".", false, false],
+ ];
+ }
+
+ /** @dataProvider providePidFileChecks */
+ public function testCheckPidFiles(string $file, bool $accessible, $exp): void {
+ $vfs = vfsStream::setup("pidtest", 0777, $this->pidfiles);
+ $path = $vfs->url()."/";
+ // set up access blocks
+ chmod($path."errors/create", 0555);
+ chmod($path."errors/read", 0333);
+ chmod($path."errors/write", 0555);
+ chmod($path."errors/readwrite", 0111);
+ // set up mock daemon class
+ $this->daemon->resolveRelativePath->returns($accessible ? dirname($path.$file) : false);
+ $daemon = $this->daemon->get();
+ // perform the test
+ if ($exp instanceof \Exception) {
+ $this->assertException($exp);
+ $daemon->checkPIDFilePath($file);
+ } else {
+ $this->assertSame($path.$exp, $daemon->checkPIDFilePath($file));
+ }
+ }
+
+ public function providePidFileChecks(): iterable {
+ return [
+ ["ok/file", false, new Exception("pidDirUnresolvable")],
+ ["not/found", true, new Exception("pidDirMissing")],
+ ["errors/create/pid", true, new Exception("pidUncreatable")],
+ ["errors/read", true, new Exception("pidUnreadable")],
+ ["errors/write", true, new Exception("pidUnwritable")],
+ ["errors/readwrite", true, new Exception("pidUnusable")],
+ ["", true, new Exception("pidNotFile")],
+ ["ok/dir", true, new Exception("pidNotFile")],
+ ["ok/file", true, "ok/file"],
+ ["ok/dir/file", true, "ok/dir/file"],
+ ];
+ }
+
+ /** @dataProvider providePidReadChecks */
+ public function testCheckPidReads(string $file, $exp) {
+ $vfs = vfsStream::setup("pidtest", 0777, $this->pidfiles);
+ $path = $vfs->url()."/pid/";
+ // set up access blocks
+ $f = fopen($path."locked", "r+");
+ flock($f, \LOCK_EX | \LOCK_NB);
+ chmod($path."unreadable", 0333);
+ chmod($path."unwritable", 0555);
+ // set up mock daemon class
+ $this->daemon->processExists->with(2112)->returns(true);
+ $this->daemon->processExists->with(42)->returns(false);
+ $daemon = $this->daemon->get();
+ // perform the test
+ try {
+ if ($exp instanceof \Exception) {
+ $this->assertException($exp);
+ $daemon->checkPID($path.$file);
+ } else {
+ $this->assertSame($exp, $daemon->checkPID($path.$file));
+ }
+ } finally {
+ flock($f, \LOCK_UN);
+ fclose($f);
+ }
+ }
+
+ public function providePidReadChecks(): iterable {
+ return [
+ ["current", new Exception("pidDuplicate")],
+ ["malformed1", new Exception("pidCorrupt")],
+ ["malformed2", new Exception("pidCorrupt")],
+ ["malformed3", new Exception("pidCorrupt")],
+ ["bogus1", new Exception("pidCorrupt")],
+ ["bogus2", new Exception("pidCorrupt")],
+ ["bogus3", new Exception("pidCorrupt")],
+ ["overlong", new Exception("pidCorrupt")],
+ ["unreadable", new Exception("pidInaccessible")],
+ ["unwritable", null],
+ ["locked", null],
+ ["missing", null],
+ ["stale", null],
+ ["empty", null],
+ ];
+ }
+
+ /**
+ * @dataProvider providePidWriteChecks
+ * @requires extension posix
+ */
+ public function testCheckPidWrites(string $file, $exp) {
+ $pid = (string) posix_getpid();
+ $vfs = vfsStream::setup("pidtest", 0777, $this->pidfiles);
+ $path = $vfs->url()."/pid/";
+ // set up access blocks
+ $f = fopen($path."locked", "r+");
+ flock($f, \LOCK_EX | \LOCK_NB);
+ chmod($path."unreadable", 0333);
+ chmod($path."unwritable", 0555);
+ // set up mock daemon class
+ $this->daemon->processExists->with(2112)->returns(true);
+ $this->daemon->processExists->with(42)->returns(false);
+ $daemon = $this->daemon->get();
+ // perform the test
+ try {
+ if ($exp instanceof \Exception) {
+ $this->assertException($exp);
+ $exp = $this->pidfiles['pid'][$file] ?? false;
+ $daemon->writePID($path.$file);
+ } else {
+ $this->assertSame($exp, $daemon->writePID($path.$file));
+ $exp = $pid;
+ }
+ } finally {
+ flock($f, \LOCK_UN);
+ fclose($f);
+ chmod($path."unreadable", 0777);
+ $this->assertSame($exp, @file_get_contents($path.$file));
+ }
+ }
+
+ public function providePidWriteChecks(): iterable {
+ return [
+ ["current", new Exception("pidDuplicate")],
+ ["malformed1", new Exception("pidCorrupt")],
+ ["malformed2", new Exception("pidCorrupt")],
+ ["malformed3", new Exception("pidCorrupt")],
+ ["bogus1", new Exception("pidCorrupt")],
+ ["bogus2", new Exception("pidCorrupt")],
+ ["bogus3", new Exception("pidCorrupt")],
+ ["overlong", new Exception("pidCorrupt")],
+ ["unreadable", new Exception("pidInaccessible")],
+ ["unwritable", new Exception("pidInaccessible")],
+ ["locked", new Exception("pidLocked")],
+ ["missing", null],
+ ["stale", null],
+ ["empty", null],
+ ];
+ }
+}
diff --git a/tests/cases/Service/TestSerial.php b/tests/cases/Service/TestSerial.php
index 93209798..321e150e 100644
--- a/tests/cases/Service/TestSerial.php
+++ b/tests/cases/Service/TestSerial.php
@@ -14,9 +14,10 @@ use JKingWeb\Arsse\Service\Serial\Driver;
/** @covers \JKingWeb\Arsse\Service\Serial\Driver */
class TestSerial extends \JKingWeb\Arsse\Test\AbstractTest {
public function setUp(): void {
- self::clearData();
+ parent::setUp();
self::setConf();
- Arsse::$db = \Phake::mock(Database::class);
+ $this->dbMock = $this->mock(Database::class);
+ Arsse::$db = $this->dbMock->get();
}
public function testConstruct(): void {
@@ -40,8 +41,8 @@ class TestSerial extends \JKingWeb\Arsse\Test\AbstractTest {
$d = new Driver;
$d->queue(1, 4, 3);
$this->assertSame(Arsse::$conf->serviceQueueWidth, $d->exec());
- \Phake::verify(Arsse::$db)->feedUpdate(1);
- \Phake::verify(Arsse::$db)->feedUpdate(4);
- \Phake::verify(Arsse::$db)->feedUpdate(3);
+ $this->dbMock->feedUpdate->calledWith(1);
+ $this->dbMock->feedUpdate->calledWith(4);
+ $this->dbMock->feedUpdate->calledWith(3);
}
}
diff --git a/tests/cases/Service/TestService.php b/tests/cases/Service/TestService.php
index 804cd553..277df8f1 100644
--- a/tests/cases/Service/TestService.php
+++ b/tests/cases/Service/TestService.php
@@ -16,16 +16,18 @@ class TestService extends \JKingWeb\Arsse\Test\AbstractTest {
protected $srv;
public function setUp(): void {
- self::clearData();
+ parent::setUp();
self::setConf();
- Arsse::$db = \Phake::mock(Database::class);
+ $this->dbMock = $this->mock(Database::class);
+ Arsse::$db = $this->dbMock->get();
$this->srv = new Service();
}
public function testCheckIn(): void {
$now = time();
$this->srv->checkIn();
- \Phake::verify(Arsse::$db)->metaSet("service_last_checkin", \Phake::capture($then), "datetime");
+ $this->dbMock->metaSet->calledWith("service_last_checkin", "~", "datetime");
+ $then = $this->dbMock->metaSet->firstCall()->argument(1);
$this->assertTime($now, $then);
}
@@ -35,49 +37,69 @@ class TestService extends \JKingWeb\Arsse\Test\AbstractTest {
$interval = Arsse::$conf->serviceFrequency;
$valid = (new \DateTimeImmutable("now", new \DateTimezone("UTC")))->sub($interval);
$invalid = $valid->sub($interval)->sub($interval);
- \Phake::when(Arsse::$db)->metaGet("service_last_checkin")->thenReturn(Date::transform($valid, "sql"))->thenReturn(Date::transform($invalid, "sql"));
+ $this->dbMock->metaGet->with("service_last_checkin")->returns(Date::transform($valid, "sql"), Date::transform($invalid, "sql"));
+ Arsse::$db = $this->dbMock->get();
$this->assertTrue(Service::hasCheckedIn());
$this->assertFalse(Service::hasCheckedIn());
}
public function testPerformPreCleanup(): void {
$this->assertTrue(Service::cleanupPre());
- \Phake::verify(Arsse::$db)->feedCleanup();
- \Phake::verify(Arsse::$db)->sessionCleanup();
+ $this->dbMock->feedCleanup->called();
+ $this->dbMock->iconCleanup->called();
+ $this->dbMock->sessionCleanup->called();
}
public function testPerformShortPostCleanup(): void {
- \Phake::when(Arsse::$db)->articleCleanup()->thenReturn(0);
+ $this->dbMock->articleCleanup->returns(0);
+ Arsse::$db = $this->dbMock->get();
$this->assertTrue(Service::cleanupPost());
- \Phake::verify(Arsse::$db)->articleCleanup();
- \Phake::verify(Arsse::$db, \Phake::times(0))->driverMaintenance();
+ $this->dbMock->articleCleanup->Called();
+ $this->dbMock->driverMaintenance->never()->called();
}
public function testPerformFullPostCleanup(): void {
- \Phake::when(Arsse::$db)->articleCleanup()->thenReturn(1);
+ $this->dbMock->articleCleanup->returns(1);
+ Arsse::$db = $this->dbMock->get();
$this->assertTrue(Service::cleanupPost());
- \Phake::verify(Arsse::$db)->articleCleanup();
- \Phake::verify(Arsse::$db)->driverMaintenance();
+ $this->dbMock->articleCleanup->called();
+ $this->dbMock->driverMaintenance->called();
}
public function testRefreshFeeds(): void {
// set up mock database actions
- \Phake::when(Arsse::$db)->metaSet->thenReturn(true);
- \Phake::when(Arsse::$db)->feedCleanup->thenReturn(true);
- \Phake::when(Arsse::$db)->sessionCleanup->thenReturn(true);
- \Phake::when(Arsse::$db)->articleCleanup->thenReturn(0);
- \Phake::when(Arsse::$db)->feedListStale->thenReturn([1,2,3]);
+ $this->dbMock->metaSet->returns(true);
+ $this->dbMock->feedCleanup->returns(true);
+ $this->dbMock->sessionCleanup->returns(true);
+ $this->dbMock->articleCleanup->returns(0);
+ $this->dbMock->feedListStale->returns([1,2,3]);
// perform the test
- $d = \Phake::mock(\JKingWeb\Arsse\Service\Driver::class);
- $s = new \JKingWeb\Arsse\Test\Service($d);
+ Arsse::$db = $this->dbMock->get();
+ $d = $this->mock(\JKingWeb\Arsse\Service\Driver::class);
+ $s = new \JKingWeb\Arsse\Test\Service($d->get());
$this->assertInstanceOf(\DateTimeInterface::class, $s->watch(false));
// verify invocations
- \Phake::verify($d)->queue(1, 2, 3);
- \Phake::verify($d)->exec();
- \Phake::verify($d)->clean();
- \Phake::verify(Arsse::$db)->feedCleanup();
- \Phake::verify(Arsse::$db)->sessionCleanup();
- \Phake::verify(Arsse::$db)->articleCleanup();
- \Phake::verify(Arsse::$db)->metaSet("service_last_checkin", $this->anything(), "datetime");
+ $d->queue->calledWith(1, 2, 3);
+ $d->exec->called();
+ $d->clean->called();
+ $this->dbMock->feedCleanup->called();
+ $this->dbMock->iconCleanup->called();
+ $this->dbMock->sessionCleanup->called();
+ $this->dbMock->articleCleanup->called();
+ $this->dbMock->metaSet->calledWith("service_last_checkin", $this->anything(), "datetime");
+ }
+
+ public function testReloadTheService(): void {
+ $u = Arsse::$user;
+ $l = Arsse::$lang;
+ $d = Arsse::$db;
+ $o = Arsse::$obj;
+ $c = Arsse::$conf;
+ $this->srv->reload();
+ $this->assertNotSame($u, Arsse::$user);
+ $this->assertNotSame($l, Arsse::$lang);
+ $this->assertNotSame($d, Arsse::$db);
+ $this->assertNotSame($o, Arsse::$obj);
+ $this->assertNotSame($c, Arsse::$conf);
}
}
diff --git a/tests/cases/Service/TestSubprocess.php b/tests/cases/Service/TestSubprocess.php
index b6ca047d..61eefa69 100644
--- a/tests/cases/Service/TestSubprocess.php
+++ b/tests/cases/Service/TestSubprocess.php
@@ -13,7 +13,7 @@ use JKingWeb\Arsse\Service\Subprocess\Driver;
/** @covers \JKingWeb\Arsse\Service\Subprocess\Driver */
class TestSubprocess extends \JKingWeb\Arsse\Test\AbstractTest {
public function setUp(): void {
- self::clearData();
+ parent::setUp();
self::setConf();
}
@@ -35,13 +35,14 @@ class TestSubprocess extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function testRefreshFeeds(): void {
- $d = \Phake::partialMock(Driver::class);
- \Phake::when($d)->execCmd->thenReturnCallback(function(string $cmd) {
+ $dMock = $this->partialMock(Driver::class);
+ $dMock->execCmd->does(function(string $cmd) {
// FIXME: Does this work in Windows?
return popen("echo ".escapeshellarg($cmd), "r");
});
+ $d = $dMock->get();
$this->assertSame(3, $d->queue(1, 4, 3));
$this->assertSame(Arsse::$conf->serviceQueueWidth, $d->exec());
- \Phake::verify($d, \Phake::times(3))->execCmd;
+ $dMock->execCmd->times(3)->called();
}
}
diff --git a/tests/cases/TestArsse.php b/tests/cases/TestArsse.php
index a15a8009..95ca9462 100644
--- a/tests/cases/TestArsse.php
+++ b/tests/cases/TestArsse.php
@@ -6,6 +6,7 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase;
+use JKingWeb\Arsse\Exception;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Conf;
use JKingWeb\Arsse\Lang;
@@ -17,22 +18,23 @@ class TestArsse extends \JKingWeb\Arsse\Test\AbstractTest {
public function setUp(): void {
self::clearData(false);
}
- public function tearDown(): void {
- self::clearData();
- }
public function testLoadExistingData(): void {
- $lang = Arsse::$lang = \Phake::mock(Lang::class);
- $db = Arsse::$db = \Phake::mock(Database::class);
- $user = Arsse::$user = \Phake::mock(User::class);
- $conf1 = Arsse::$conf = \Phake::mock(Conf::class);
+ $lang = $this->mock(Lang::class);
+ $db = $this->mock(Database::class);
+ $user = $this->mock(User::class);
+ $conf1 = $this->mock(Conf::class);
+ Arsse::$lang = $lang->get();
+ Arsse::$db = $db->get();
+ Arsse::$user = $user->get();
+ Arsse::$conf = $conf1->get();
$conf2 = (new Conf)->import(['lang' => "test"]);
Arsse::load($conf2);
$this->assertSame($conf2, Arsse::$conf);
- $this->assertSame($lang, Arsse::$lang);
- $this->assertSame($db, Arsse::$db);
- $this->assertSame($user, Arsse::$user);
- \Phake::verify($lang)->set("test");
+ $this->assertSame($lang->get(), Arsse::$lang);
+ $this->assertSame($db->get(), Arsse::$db);
+ $this->assertSame($user->get(), Arsse::$user);
+ $lang->set->calledWith("test");
}
public function testLoadNewData(): void {
@@ -46,4 +48,23 @@ class TestArsse extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertInstanceOf(Database::class, Arsse::$db);
$this->assertInstanceOf(User::class, Arsse::$user);
}
+
+ /** @dataProvider provideExtensionChecks */
+ public function testCheckForExtensions(array $ext, $exp): void {
+ if ($exp instanceof \Exception) {
+ $this->assertException($exp);
+ Arsse::checkExtensions(...$ext);
+ } else {
+ $this->assertNull(Arsse::checkExtensions(...$ext));
+ }
+ }
+
+ public function provideExtensionChecks(): iterable {
+ return [
+ [["pcre"], null],
+ [["foo", "bar", "baz"], new Exception("extMissing", ['first' => "foo", 'total' => 3])],
+ [["bar", "baz"], new Exception("extMissing", ['first' => "bar", 'total' => 2])],
+ [["baz"], new Exception("extMissing", ['first' => "baz", 'total' => 1])],
+ ];
+ }
}
diff --git a/tests/cases/User/TestInternal.php b/tests/cases/User/TestInternal.php
index 4333771d..12f4386d 100644
--- a/tests/cases/User/TestInternal.php
+++ b/tests/cases/User/TestInternal.php
@@ -14,11 +14,16 @@ use JKingWeb\Arsse\User\Internal\Driver;
/** @covers \JKingWeb\Arsse\User\Internal\Driver */
class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest {
public function setUp(): void {
- self::clearData();
+ parent::setUp();
self::setConf();
// create a mock database interface
- Arsse::$db = \Phake::mock(Database::class);
- \Phake::when(Arsse::$db)->begin->thenReturn(\Phake::mock(\JKingWeb\Arsse\Db\Transaction::class));
+ $this->dbMock = $this->mock(Database::class);
+ $this->dbMock->begin->returns($this->mock(\JKingWeb\Arsse\Db\Transaction::class));
+ }
+
+ protected function prepTest(): Driver {
+ Arsse::$db = $this->dbMock->get();
+ return new Driver;
}
public function testConstruct(): void {
@@ -33,17 +38,13 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest {
* @dataProvider provideAuthentication
* @group slow
*/
- public function testAuthenticateAUser(bool $authorized, string $user, $password, bool $exp): void {
- if ($authorized) {
- \Phake::when(Arsse::$db)->userPasswordGet("john.doe@example.com")->thenReturn('$2y$10$1zbqRJhxM8uUjeSBPp4IhO90xrqK0XjEh9Z16iIYEFRV4U.zeAFom'); // hash of "secret"
- \Phake::when(Arsse::$db)->userPasswordGet("jane.doe@example.com")->thenReturn('$2y$10$bK1ljXfTSyc2D.NYvT.Eq..OpehLRXVbglW.23ihVuyhgwJCd.7Im'); // hash of "superman"
- \Phake::when(Arsse::$db)->userPasswordGet("owen.hardy@example.com")->thenReturn("");
- \Phake::when(Arsse::$db)->userPasswordGet("kira.nerys@example.com")->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist"));
- \Phake::when(Arsse::$db)->userPasswordGet("007@example.com")->thenReturn(null);
- } else {
- \Phake::when(Arsse::$db)->userPasswordGet->thenThrow(new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized"));
- }
- $this->assertSame($exp, (new Driver)->auth($user, $password));
+ public function testAuthenticateAUser(string $user, $password, bool $exp): void {
+ $this->dbMock->userPasswordGet->with("john.doe@example.com")->returns('$2y$10$1zbqRJhxM8uUjeSBPp4IhO90xrqK0XjEh9Z16iIYEFRV4U.zeAFom'); // hash of "secret"
+ $this->dbMock->userPasswordGet->with("jane.doe@example.com")->returns('$2y$10$bK1ljXfTSyc2D.NYvT.Eq..OpehLRXVbglW.23ihVuyhgwJCd.7Im'); // hash of "superman"
+ $this->dbMock->userPasswordGet->with("owen.hardy@example.com")->returns("");
+ $this->dbMock->userPasswordGet->with("kira.nerys@example.com")->throws(new \JKingWeb\Arsse\User\ExceptionConflict("doesNotExist"));
+ $this->dbMock->userPasswordGet->with("007@example.com")->returns(null);
+ $this->assertSame($exp, $this->prepTest()->auth($user, $password));
}
public function provideAuthentication(): iterable {
@@ -53,100 +54,130 @@ class TestInternal extends \JKingWeb\Arsse\Test\AbstractTest {
$kira = "kira.nerys@example.com";
$bond = "007@example.com";
return [
- [false, $john, "secret", false],
- [false, $jane, "superman", false],
- [false, $owen, "", false],
- [false, $kira, "ashalla", false],
- [false, $bond, "", false],
- [true, $john, "secret", true],
- [true, $jane, "superman", true],
- [true, $owen, "", true],
- [true, $kira, "ashalla", false],
- [true, $john, "top secret", false],
- [true, $jane, "clark kent", false],
- [true, $owen, "watchmaker", false],
- [true, $kira, "singha", false],
- [true, $john, "", false],
- [true, $jane, "", false],
- [true, $kira, "", false],
- [true, $bond, "for England", false],
- [true, $bond, "", false],
+ [$john, "secret", true],
+ [$jane, "superman", true],
+ [$owen, "", true],
+ [$kira, "ashalla", false],
+ [$john, "top secret", false],
+ [$jane, "clark kent", false],
+ [$owen, "watchmaker", false],
+ [$kira, "singha", false],
+ [$john, "", false],
+ [$jane, "", false],
+ [$kira, "", false],
+ [$bond, "for England", false],
+ [$bond, "", false],
];
}
- public function testAuthorizeAnAction(): void {
- \Phake::verifyNoFurtherInteraction(Arsse::$db);
- $this->assertTrue((new Driver)->authorize("someone", "something"));
- }
-
public function testListUsers(): void {
$john = "john.doe@example.com";
$jane = "jane.doe@example.com";
- \Phake::when(Arsse::$db)->userList->thenReturn([$john, $jane])->thenReturn([$jane, $john]);
- $driver = new Driver;
+ $this->dbMock->userList->returns([$john, $jane])->returns([$jane, $john]);
+ $driver = $this->prepTest();
$this->assertSame([$john, $jane], $driver->userList());
$this->assertSame([$jane, $john], $driver->userList());
- \Phake::verify(Arsse::$db, \Phake::times(2))->userList;
- }
-
- public function testCheckThatAUserExists(): void {
- $john = "john.doe@example.com";
- $jane = "jane.doe@example.com";
- \Phake::when(Arsse::$db)->userExists($john)->thenReturn(true);
- \Phake::when(Arsse::$db)->userExists($jane)->thenReturn(false);
- $driver = new Driver;
- $this->assertTrue($driver->userExists($john));
- \Phake::verify(Arsse::$db)->userExists($john);
- $this->assertFalse($driver->userExists($jane));
- \Phake::verify(Arsse::$db)->userExists($jane);
+ $this->dbMock->userList->times(2)->called();
}
public function testAddAUser(): void {
$john = "john.doe@example.com";
- \Phake::when(Arsse::$db)->userAdd->thenReturnCallback(function($user, $pass) {
+ $this->dbMock->userAdd->does(function($user, $pass) {
return $pass;
});
- $driver = new Driver;
+ $driver = $this->prepTest();
$this->assertNull($driver->userAdd($john));
$this->assertNull($driver->userAdd($john, null));
$this->assertSame("secret", $driver->userAdd($john, "secret"));
- \Phake::verify(Arsse::$db)->userAdd($john, "secret");
- \Phake::verify(Arsse::$db)->userAdd;
+ $this->dbMock->userAdd->calledWith($john, "secret");
+ $this->dbMock->userAdd->called();
+ }
+
+ public function testRenameAUser(): void {
+ $john = "john.doe@example.com";
+ $this->dbMock->userExists->returns(true);
+ $this->assertTrue($this->prepTest()->userRename($john, "jane.doe@example.com"));
+ $this->assertFalse($this->prepTest()->userRename($john, $john));
+ $this->dbMock->userExists->times(2)->calledWith($john);
+ }
+
+ public function testRenameAMissingUser(): void {
+ $john = "john.doe@example.com";
+ $this->dbMock->userExists->returns(false);
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ $this->prepTest()->userRename($john, "jane.doe@example.com");
}
public function testRemoveAUser(): void {
$john = "john.doe@example.com";
- \Phake::when(Arsse::$db)->userRemove->thenReturn(true)->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist"));
- $driver = new Driver;
+ $this->dbMock->userRemove->returns(true)->throws(new \JKingWeb\Arsse\User\ExceptionConflict("doesNotExist"));
+ $driver = $this->prepTest();
$this->assertTrue($driver->userRemove($john));
- \Phake::verify(Arsse::$db, \Phake::times(1))->userRemove($john);
- $this->assertException("doesNotExist", "User");
+ $this->dbMock->userRemove->calledWith($john);
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
try {
$this->assertFalse($driver->userRemove($john));
} finally {
- \Phake::verify(Arsse::$db, \Phake::times(2))->userRemove($john);
+ $this->dbMock->userRemove->times(2)->calledWith($john);
}
}
public function testSetAPassword(): void {
$john = "john.doe@example.com";
- \Phake::verifyNoFurtherInteraction(Arsse::$db);
- $this->assertSame("superman", (new Driver)->userPasswordSet($john, "superman"));
- $this->assertSame(null, (new Driver)->userPasswordSet($john, null));
+ $this->dbMock->userExists->returns(true);
+ $this->assertSame("superman", $this->prepTest()->userPasswordSet($john, "superman"));
+ $this->assertSame(null, $this->prepTest()->userPasswordSet($john, null));
+ $this->dbMock->userPasswordSet->never()->called();
+ }
+
+ public function testSetAPasswordForAMssingUser(): void {
+ $this->dbMock->userExists->returns(false);
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ $this->prepTest()->userPasswordSet("john.doe@example.com", "secret");
}
public function testUnsetAPassword(): void {
- $drv = \Phake::partialMock(Driver::class);
- \Phake::when($drv)->userExists->thenReturn(true);
- \Phake::verifyNoFurtherInteraction(Arsse::$db);
- $this->assertTrue($drv->userPasswordUnset("john.doe@example.com"));
+ $this->dbMock->userExists->returns(true);
+ $this->assertTrue($this->prepTest()->userPasswordUnset("john.doe@example.com"));
+ $this->dbMock->userPasswordSet->never()->called();
}
public function testUnsetAPasswordForAMssingUser(): void {
- $drv = \Phake::partialMock(Driver::class);
- \Phake::when($drv)->userExists->thenReturn(false);
- \Phake::verifyNoFurtherInteraction(Arsse::$db);
- $this->assertException("doesNotExist", "User");
- $drv->userPasswordUnset("john.doe@example.com");
+ $this->dbMock->userExists->returns(false);
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ $this->prepTest()->userPasswordUnset("john.doe@example.com");
+ }
+
+ public function testGetUserProperties(): void {
+ $this->dbMock->userExists->returns(true);
+ $this->assertSame([], $this->prepTest()->userPropertiesGet("john.doe@example.com"));
+ $this->dbMock->userExists->calledWith("john.doe@example.com");
+ }
+
+ public function testGetPropertiesForAMissingUser(): void {
+ $this->dbMock->userExists->returns(false);
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ try {
+ $this->prepTest()->userPropertiesGet("john.doe@example.com");
+ } finally {
+ $this->dbMock->userExists->calledWith("john.doe@example.com");
+ }
+ }
+
+ public function testSetUserProperties(): void {
+ $in = ['admin' => true];
+ $this->dbMock->userExists->returns(true);
+ $this->assertSame($in, $this->prepTest()->userPropertiesSet("john.doe@example.com", $in));
+ $this->dbMock->userExists->calledWith("john.doe@example.com");
+ }
+
+ public function testSetPropertiesForAMissingUser(): void {
+ $this->dbMock->userExists->returns(false);
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ try {
+ $this->prepTest()->userPropertiesSet("john.doe@example.com", ['admin' => true]);
+ } finally {
+ $this->dbMock->userExists->calledWith("john.doe@example.com");
+ }
}
}
diff --git a/tests/cases/User/TestUser.php b/tests/cases/User/TestUser.php
index 93b5ee72..0066028d 100644
--- a/tests/cases/User/TestUser.php
+++ b/tests/cases/User/TestUser.php
@@ -6,26 +6,40 @@
declare(strict_types=1);
namespace JKingWeb\Arsse\TestCase\User;
+use Eloquent\Phony\Phpunit\Phony;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\User;
-use JKingWeb\Arsse\AbstractException as Exception;
+use JKingWeb\Arsse\Db\Transaction;
+use JKingWeb\Arsse\User\ExceptionConflict;
+use JKingWeb\Arsse\User\ExceptionInput;
use JKingWeb\Arsse\User\Driver;
/** @covers \JKingWeb\Arsse\User */
class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
public function setUp(): void {
- self::clearData();
+ parent::setUp();
self::setConf();
// create a mock database interface
- Arsse::$db = \Phake::mock(Database::class);
- \Phake::when(Arsse::$db)->begin->thenReturn(\Phake::mock(\JKingWeb\Arsse\Db\Transaction::class));
+ $this->dbMock = $this->mock(Database::class);
+ $this->dbMock->begin->returns($this->mock(\JKingWeb\Arsse\Db\Transaction::class));
// create a mock user driver
- $this->drv = \Phake::mock(Driver::class);
+ $this->drv = $this->mock(Driver::class);
+ }
+
+ protected function prepTest(?\Closure $partialMockDef = null): User {
+ Arsse::$db = $this->dbMock->get();
+ if ($partialMockDef) {
+ $this->userMock = $this->partialMock(User::class, $this->drv->get());
+ $partialMockDef($this->userMock);
+ return $this->userMock->get();
+ } else {
+ return new User($this->drv->get());
+ }
}
public function testConstruct(): void {
- $this->assertInstanceOf(User::class, new User($this->drv));
+ $this->assertInstanceOf(User::class, new User($this->drv->get()));
$this->assertInstanceOf(User::class, new User);
}
@@ -37,20 +51,34 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertSame("", (string) $u);
}
+ public function testStartATransaction(): void {
+ $u = $this->prepTest();
+ $this->assertInstanceOf(Transaction::class, $u->begin());
+ $this->dbMock->begin->calledWith();
+ }
+
+ public function testGeneratePasswords(): void {
+ $u = $this->prepTest();
+ $pass1 = $u->generatePassword();
+ $pass2 = $u->generatePassword();
+ $this->assertNotEquals($pass1, $pass2);
+ }
+
/** @dataProvider provideAuthentication */
public function testAuthenticateAUser(bool $preAuth, string $user, string $password, bool $exp): void {
Arsse::$conf->userPreAuth = $preAuth;
- \Phake::when($this->drv)->auth->thenReturn(false);
- \Phake::when($this->drv)->auth("john.doe@example.com", "secret")->thenReturn(true);
- \Phake::when($this->drv)->auth("jane.doe@example.com", "superman")->thenReturn(true);
- \Phake::when(Arsse::$db)->userExists("john.doe@example.com")->thenReturn(true);
- \Phake::when(Arsse::$db)->userExists("jane.doe@example.com")->thenReturn(false);
- \Phake::when(Arsse::$db)->userAdd->thenReturn("");
- $u = new User($this->drv);
+ $this->drv->auth->returns(false);
+ $this->drv->auth->with("john.doe@example.com", "secret")->returns(true);
+ $this->drv->auth->with("jane.doe@example.com", "superman")->returns(true);
+ $this->dbMock->userExists->with("john.doe@example.com")->returns(true);
+ $this->dbMock->userExists->with("jane.doe@example.com")->returns(false);
+ $this->dbMock->userAdd->returns("");
+ $u = $this->prepTest();
$this->assertSame($exp, $u->auth($user, $password));
$this->assertNull($u->id);
- \Phake::verify(Arsse::$db, \Phake::times($exp ? 1 : 0))->userExists($user);
- \Phake::verify(Arsse::$db, \Phake::times($exp && $user === "jane.doe@example.com" ? 1 : 0))->userAdd($user, $password);
+ $this->drv->auth->times((int) !$preAuth)->called();
+ $this->dbMock->userExists->times($exp ? 1 : 0)->calledWith($user);
+ $this->dbMock->userAdd->times($exp && $user === "jane.doe@example.com" ? 1 : 0)->calledWith($user, $password);
}
public function provideAuthentication(): iterable {
@@ -68,261 +96,449 @@ class TestUser extends \JKingWeb\Arsse\Test\AbstractTest {
];
}
- /** @dataProvider provideUserList */
- public function testListUsers(bool $authorized, $exp): void {
- $u = new User($this->drv);
- \Phake::when($this->drv)->authorize->thenReturn($authorized);
- \Phake::when($this->drv)->userList->thenReturn(["john.doe@example.com", "jane.doe@example.com"]);
- if ($exp instanceof Exception) {
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- }
+ public function testListUsers(): void {
+ $exp = ["john.doe@example.com", "jane.doe@example.com"];
+ $this->drv->userList->returns(["john.doe@example.com", "jane.doe@example.com"]);
+ $u = $this->prepTest();
$this->assertSame($exp, $u->list());
+ $this->drv->userList->calledWith();
}
- public function provideUserList(): iterable {
- $john = "john.doe@example.com";
- $jane = "jane.doe@example.com";
+ public function testLookUpAUserByNumber(): void {
+ $exp = "john.doe@example.com";
+ $this->dbMock->userLookup->returns($exp);
+ $u = $this->prepTest();
+ $this->assertSame($exp, $u->lookup(2112));
+ $this->dbMock->userLookup->calledWith(2112);
+ }
+
+ public function testAddAUser(): void {
+ $user = "john.doe@example.com";
+ $pass = "secret";
+ $this->drv->userAdd->returns($pass);
+ $this->dbMock->userExists->returns(true);
+ $u = $this->prepTest();
+ $this->assertSame($pass, $u->add($user, $pass));
+ $this->drv->userAdd->calledWith($user, $pass);
+ $this->dbMock->userExists->calledWith($user);
+ }
+
+ public function testAddAUserWeDoNotKnow(): void {
+ $user = "john.doe@example.com";
+ $pass = "secret";
+ $this->drv->userAdd->returns($pass);
+ $this->dbMock->userExists->returns(false);
+ $u = $this->prepTest();
+ $this->assertSame($pass, $u->add($user, $pass));
+ $this->drv->userAdd->calledWith($user, $pass);
+ $this->dbMock->userExists->calledWith($user);
+ $this->dbMock->userAdd->calledWith($user, $pass);
+ }
+
+ public function testAddADuplicateUser(): void {
+ $user = "john.doe@example.com";
+ $pass = "secret";
+ $this->drv->userAdd->throws(new ExceptionConflict("alreadyExists"));
+ $this->dbMock->userExists->returns(true);
+ $u = $this->prepTest();
+ $this->assertException("alreadyExists", "User", "ExceptionConflict");
+ try {
+ $u->add($user, $pass);
+ } finally {
+ $this->dbMock->userExists->calledWith($user);
+ $this->drv->userAdd->calledWith($user, $pass);
+ }
+ }
+
+ public function testAddADuplicateUserWeDoNotKnow(): void {
+ $user = "john.doe@example.com";
+ $pass = "secret";
+ $this->drv->userAdd->throws(new ExceptionConflict("alreadyExists"));
+ $this->dbMock->userExists->returns(false);
+ $u = $this->prepTest();
+ $this->assertException("alreadyExists", "User", "ExceptionConflict");
+ try {
+ $u->add($user, $pass);
+ } finally {
+ $this->dbMock->userExists->calledWith($user);
+ $this->dbMock->userAdd->calledWith($user, null);
+ $this->drv->userAdd->calledWith($user, $pass);
+ }
+ }
+
+ /** @dataProvider provideInvalidUserNames */
+ public function testAddAnInvalidUser(string $user): void {
+ $u = $this->prepTest();
+ $this->assertException("invalidUsername", "User", "ExceptionInput");
+ $u->add($user, "secret");
+ }
+
+ public function provideInvalidUserNames(): iterable {
+ // output names with control characters
+ foreach (array_merge(range(0x00, 0x1F), [0x7F]) as $ord) {
+ yield [chr($ord)];
+ yield ["john".chr($ord)."doe@example.com"];
+ }
+ // also handle colons
+ yield [":"];
+ yield ["john:doe@example.com"];
+ }
+
+ public function testAddAUserWithARandomPassword(): void {
+ $user = "john.doe@example.com";
+ $pass = "random password";
+ $this->drv->userAdd->returns(null)->returns($pass);
+ $this->dbMock->userExists->returns(true);
+ $u = $this->prepTest(function($u) use ($pass) {
+ $u->generatePassword->returns($pass);
+ });
+ $this->assertSame($pass, $u->add($user));
+ $this->drv->userAdd->calledWith($user, null);
+ $this->drv->userAdd->calledWith($user, $pass);
+ $this->dbMock->userExists->calledWith($user);
+ }
+
+ public function testRenameAUser(): void {
+ $tr = $this->mock(Transaction::class);
+ $this->dbMock->begin->returns($tr);
+ $this->dbMock->userExists->returns(true);
+ $this->dbMock->userAdd->returns(true);
+ $this->dbMock->userRename->returns(true);
+ $this->drv->userRename->returns(true);
+ $u = $this->prepTest();
+ $old = "john.doe@example.com";
+ $new = "jane.doe@example.com";
+ $this->assertTrue($u->rename($old, $new));
+ Phony::inOrder(
+ $this->drv->userRename->calledWith($old, $new),
+ $this->dbMock->begin->calledWith(),
+ $this->dbMock->userExists->calledWith($old),
+ $this->dbMock->userRename->calledWith($old, $new),
+ $this->dbMock->sessionDestroy->calledWith($new),
+ $this->dbMock->tokenRevoke->calledWith($new, "fever.login"),
+ $tr->commit->called()
+ );
+ }
+
+ public function testRenameAUserWeDoNotKnow(): void {
+ $tr = $this->mock(Transaction::class);
+ $this->dbMock->begin->returns($tr);
+ $this->dbMock->userExists->returns(false);
+ $this->dbMock->userAdd->returns(true);
+ $this->dbMock->userRename->returns(true);
+ $this->drv->userRename->returns(true);
+ $u = $this->prepTest();
+ $old = "john.doe@example.com";
+ $new = "jane.doe@example.com";
+ $this->assertTrue($u->rename($old, $new));
+ Phony::inOrder(
+ $this->drv->userRename->calledWith($old, $new),
+ $this->dbMock->begin->calledWith(),
+ $this->dbMock->userExists->calledWith($old),
+ $this->dbMock->userAdd->calledWith($new, null),
+ $tr->commit->called()
+ );
+ }
+
+ public function testRenameAUserWithoutEffect(): void {
+ $this->dbMock->userExists->returns(false);
+ $this->dbMock->userAdd->returns(true);
+ $this->dbMock->userRename->returns(true);
+ $this->drv->userRename->returns(false);
+ $u = $this->prepTest();
+ $old = "john.doe@example.com";
+ $this->assertFalse($u->rename($old, $old));
+ $this->drv->userRename->calledWith($old, $old);
+ }
+
+ /** @dataProvider provideInvalidUserNames */
+ public function testRenameAUserToAnInvalidName(string $new): void {
+ $u = $this->prepTest();
+ $this->assertException("invalidUsername", "User", "ExceptionInput");
+ $u->rename("john.doe@example.com", $new);
+ }
+
+ public function testRemoveAUser(): void {
+ $user = "john.doe@example.com";
+ $this->drv->userRemove->returns(true);
+ $this->dbMock->userExists->returns(true);
+ $u = $this->prepTest();
+ $this->assertTrue($u->remove($user));
+ $this->dbMock->userExists->calledWith($user);
+ $this->dbMock->userRemove->calledWith($user);
+ $this->drv->userRemove->calledWith($user);
+ }
+
+ public function testRemoveAUserWeDoNotKnow(): void {
+ $user = "john.doe@example.com";
+ $this->drv->userRemove->returns(true);
+ $this->dbMock->userExists->returns(false);
+ $u = $this->prepTest();
+ $this->assertTrue($u->remove($user));
+ $this->dbMock->userExists->calledWith($user);
+ $this->drv->userRemove->calledWith($user);
+ }
+
+ public function testRemoveAMissingUser(): void {
+ $user = "john.doe@example.com";
+ $pass = "secret";
+ $this->drv->userRemove->throws(new ExceptionConflict("doesNotExist"));
+ $this->dbMock->userExists->returns(true);
+ $u = $this->prepTest();
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ try {
+ $u->remove($user);
+ } finally {
+ $this->dbMock->userExists->calledWith($user);
+ $this->dbMock->userRemove->calledWith($user);
+ $this->drv->userRemove->calledWith($user);
+ }
+ }
+
+ public function testRemoveAMissingUserWeDoNotKnow(): void {
+ $user = "john.doe@example.com";
+ $pass = "secret";
+ $this->drv->userRemove->throws(new ExceptionConflict("doesNotExist"));
+ $this->dbMock->userExists->returns(false);
+ $u = $this->prepTest();
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ try {
+ $u->remove($user);
+ } finally {
+ $this->dbMock->userExists->calledWith($user);
+ $this->drv->userRemove->calledWith($user);
+ }
+ }
+
+ public function testSetAPassword(): void {
+ $user = "john.doe@example.com";
+ $pass = "secret";
+ $this->drv->userPasswordSet->returns($pass);
+ $this->dbMock->userPasswordSet->returns($pass);
+ $this->dbMock->userExists->returns(true);
+ $u = $this->prepTest();
+ $this->assertSame($pass, $u->passwordSet($user, $pass));
+ $this->drv->userPasswordSet->calledWith($user, $pass, null);
+ $this->dbMock->userPasswordSet->calledWith($user, $pass);
+ $this->dbMock->sessionDestroy->calledWith($user);
+ $this->dbMock->userExists->calledWith($user);
+ }
+
+ public function testSetARandomPassword(): void {
+ $user = "john.doe@example.com";
+ $pass = "random password";
+ $this->drv->userPasswordSet->returns(null)->returns($pass);
+ $this->dbMock->userPasswordSet->returns($pass);
+ $this->dbMock->userExists->returns(true);
+ $u = $this->prepTest(function($u) use ($pass) {
+ $u->generatePassword->returns($pass);
+ });
+ $this->assertSame($pass, $u->passwordSet($user, null));
+ $this->drv->userPasswordSet->calledWith($user, null, null);
+ $this->drv->userPasswordSet->calledWith($user, $pass, null);
+ $this->dbMock->userPasswordSet->calledWith($user, $pass);
+ $this->dbMock->sessionDestroy->calledWith($user);
+ $this->dbMock->userExists->calledWith($user);
+ }
+
+ public function testSetAPasswordForAUserWeDoNotKnow(): void {
+ $user = "john.doe@example.com";
+ $pass = "secret";
+ $this->drv->userPasswordSet->returns($pass);
+ $this->dbMock->userPasswordSet->returns($pass);
+ $this->dbMock->userExists->returns(false);
+ $u = $this->prepTest();
+ $this->assertSame($pass, $u->passwordSet($user, $pass));
+ $this->drv->userPasswordSet->calledWith($user, $pass, null);
+ $this->dbMock->userAdd->calledWith($user, $pass);
+ $this->dbMock->userExists->calledWith($user);
+ }
+
+ public function testSetARandomPasswordForAUserWeDoNotKnow(): void {
+ $user = "john.doe@example.com";
+ $pass = "random password";
+ $this->drv->userPasswordSet->returns(null)->returns($pass);
+ $this->dbMock->userPasswordSet->returns($pass);
+ $this->dbMock->userExists->returns(false);
+ $u = $this->prepTest(function($u) use ($pass) {
+ $u->generatePassword->returns($pass);
+ });
+ $this->assertSame($pass, $u->passwordSet($user, null));
+ $this->drv->userPasswordSet->calledWith($user, null, null);
+ $this->drv->userPasswordSet->calledWith($user, $pass, null);
+ $this->dbMock->userAdd->calledWith($user, $pass);
+ $this->dbMock->userExists->calledWith($user);
+ }
+
+ public function testSetARandomPasswordForAMissingUser(): void {
+ $user = "john.doe@example.com";
+ $pass = "random password";
+ $this->drv->userPasswordSet->throws(new ExceptionConflict("doesNotExist"));
+ $u = $this->prepTest(function($u) use ($pass) {
+ $u->generatePassword->returns($pass);
+ });
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ try {
+ $u->passwordSet($user, null);
+ } finally {
+ $this->drv->userPasswordSet->calledWith($user, null, null);
+ }
+ }
+
+ public function testUnsetAPassword(): void {
+ $user = "john.doe@example.com";
+ $this->drv->userPasswordUnset->returns(true);
+ $this->dbMock->userPasswordSet->returns(true);
+ $this->dbMock->userExists->returns(true);
+ $u = $this->prepTest();
+ $this->assertTrue($u->passwordUnset($user));
+ $this->drv->userPasswordUnset->calledWith($user, null);
+ $this->dbMock->userPasswordSet->calledWith($user, null);
+ $this->dbMock->sessionDestroy->calledWith($user);
+ $this->dbMock->userExists->calledWith($user);
+ }
+
+ public function testUnsetAPasswordForAUserWeDoNotKnow(): void {
+ $user = "john.doe@example.com";
+ $this->drv->userPasswordUnset->returns(true);
+ $this->dbMock->userPasswordSet->returns(true);
+ $this->dbMock->userExists->returns(false);
+ $u = $this->prepTest();
+ $this->assertTrue($u->passwordUnset($user));
+ $this->drv->userPasswordUnset->calledWith($user, null);
+ $this->dbMock->userExists->calledWith($user);
+ }
+
+ public function testUnsetAPasswordForAMissingUser(): void {
+ $user = "john.doe@example.com";
+ $this->drv->userPasswordUnset->throws(new ExceptionConflict("doesNotExist"));
+ $u = $this->prepTest();
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ try {
+ $u->passwordUnset($user);
+ } finally {
+ $this->drv->userPasswordUnset->calledWith($user, null);
+ }
+ }
+
+ /** @dataProvider provideProperties */
+ public function testGetThePropertiesOfAUser(array $exp, array $base, array $extra): void {
+ $user = "john.doe@example.com";
+ $exp = array_merge(['num' => null], array_combine(array_keys(User::PROPERTIES), array_fill(0, sizeof(User::PROPERTIES), null)), $exp);
+ $this->drv->userPropertiesGet->returns($extra);
+ $this->dbMock->userPropertiesGet->returns($base);
+ $this->dbMock->userExists->returns(true);
+ $u = $this->prepTest();
+ $this->assertSame($exp, $u->propertiesGet($user));
+ $this->drv->userPropertiesGet->calledWith($user, true);
+ $this->dbMock->userPropertiesGet->calledWith($user, true);
+ $this->dbMock->userExists->calledWith($user);
+ }
+
+ public function provideProperties(): iterable {
+ $defaults = ['num' => 1, 'admin' => false, 'lang' => null, 'tz' => "Etc/UTC", 'sort_asc' => false];
return [
- [false, new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [true, [$john, $jane]],
+ [$defaults, $defaults, []],
+ [$defaults, $defaults, ['num' => 2112, 'blah' => "bloo"]],
+ [['num' => 1, 'admin' => true, 'lang' => "fr", 'tz' => "America/Toronto", 'sort_asc' => true], $defaults, ['admin' => true, 'lang' => "fr", 'tz' => "America/Toronto", 'sort_asc' => true]],
+ [['num' => 1, 'admin' => true, 'lang' => null, 'tz' => "America/Toronto", 'sort_asc' => true], ['num' => 1, 'admin' => true, 'lang' => "fr", 'tz' => "America/Toronto", 'sort_asc' => true], ['lang' => null]],
];
}
- /** @dataProvider provideExistence */
- public function testCheckThatAUserExists(bool $authorized, string $user, $exp): void {
- $u = new User($this->drv);
- \Phake::when($this->drv)->authorize->thenReturn($authorized);
- \Phake::when($this->drv)->userExists("john.doe@example.com")->thenReturn(true);
- \Phake::when($this->drv)->userExists("jane.doe@example.com")->thenReturn(false);
- if ($exp instanceof Exception) {
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
+ public function testGetThePropertiesOfAUserWeDoNotKnow(): void {
+ $user = "john.doe@example.com";
+ $extra = ['tz' => "Europe/Istanbul"];
+ $base = ['num' => 47, 'admin' => false, 'lang' => null, 'tz' => "Etc/UTC", 'sort_asc' => false];
+ $exp = ['num' => 47, 'admin' => false, 'lang' => null, 'tz' => "Europe/Istanbul", 'sort_asc' => false];
+ $exp = array_merge(['num' => null], array_combine(array_keys(User::PROPERTIES), array_fill(0, sizeof(User::PROPERTIES), null)), $exp);
+ $this->drv->userPropertiesGet->returns($extra);
+ $this->dbMock->userPropertiesGet->returns($base);
+ $this->dbMock->userAdd->returns(true);
+ $this->dbMock->userExists->returns(false);
+ $u = $this->prepTest();
+ $this->assertSame($exp, $u->propertiesGet($user));
+ $this->drv->userPropertiesGet->calledWith($user, true);
+ $this->dbMock->userPropertiesGet->calledWith($user, true);
+ $this->dbMock->userPropertiesSet->calledWith($user, $extra);
+ $this->dbMock->userAdd->calledWith($user, null);
+ $this->dbMock->userExists->calledWith($user);
+ }
+
+ public function testGetThePropertiesOfAMissingUser(): void {
+ $user = "john.doe@example.com";
+ $this->drv->userPropertiesGet->throws(new ExceptionConflict("doesNotExist"));
+ $u = $this->prepTest();
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
+ try {
+ $u->propertiesGet($user);
+ } finally {
+ $this->drv->userPropertiesGet->calledWith($user, true);
}
- $this->assertSame($exp, $u->exists($user));
}
- public function provideExistence(): iterable {
- $john = "john.doe@example.com";
- $jane = "jane.doe@example.com";
- return [
- [false, $john, new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [false, $jane, new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [true, $john, true],
- [true, $jane, false],
- ];
- }
-
- /** @dataProvider provideAdditions */
- public function testAddAUser(bool $authorized, string $user, $password, $exp): void {
- $u = new User($this->drv);
- \Phake::when($this->drv)->authorize->thenReturn($authorized);
- \Phake::when($this->drv)->userAdd("john.doe@example.com", $this->anything())->thenThrow(new \JKingWeb\Arsse\User\Exception("alreadyExists"));
- \Phake::when($this->drv)->userAdd("jane.doe@example.com", $this->anything())->thenReturnCallback(function($user, $pass) {
- return $pass ?? "random password";
- });
- if ($exp instanceof Exception) {
- if ($exp instanceof \JKingWeb\Arsse\User\ExceptionAuthz) {
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- } else {
- $this->assertException("alreadyExists", "User");
- }
- }
- $this->assertSame($exp, $u->add($user, $password));
- }
-
- /** @dataProvider provideAdditions */
- public function testAddAUserWithARandomPassword(bool $authorized, string $user, $password, $exp): void {
- $u = \Phake::partialMock(User::class, $this->drv);
- \Phake::when($this->drv)->authorize->thenReturn($authorized);
- \Phake::when($this->drv)->userAdd($this->anything(), $this->isNull())->thenReturn(null);
- \Phake::when($this->drv)->userAdd("john.doe@example.com", $this->logicalNot($this->isNull()))->thenThrow(new \JKingWeb\Arsse\User\Exception("alreadyExists"));
- \Phake::when($this->drv)->userAdd("jane.doe@example.com", $this->logicalNot($this->isNull()))->thenReturnCallback(function($user, $pass) {
- return $pass;
- });
- if ($exp instanceof Exception) {
- if ($exp instanceof \JKingWeb\Arsse\User\ExceptionAuthz) {
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- $calls = 0;
- } else {
- $this->assertException("alreadyExists", "User");
- $calls = 2;
- }
+ /** @dataProvider providePropertyChanges */
+ public function testSetThePropertiesOfAUser(array $in, $out): void {
+ $user = "john.doe@example.com";
+ if ($out instanceof \Exception) {
+ $u = $this->prepTest();
+ $this->assertException($out);
+ $u->propertiesSet($user, $in);
} else {
- $calls = 4;
- }
- try {
- $pass1 = $u->add($user, null);
- $pass2 = $u->add($user, null);
- $this->assertNotEquals($pass1, $pass2);
- } finally {
- \Phake::verify($this->drv, \Phake::times($calls))->userAdd;
- \Phake::verify($u, \Phake::times($calls / 2))->generatePassword;
+ $this->dbMock->userExists->returns(true);
+ $this->drv->userPropertiesSet->returns($out);
+ $this->dbMock->userPropertiesSet->returns(true);
+ $u = $this->prepTest();
+ $this->assertSame($out, $u->propertiesSet($user, $in));
+ $this->drv->userPropertiesSet->calledWith($user, $in);
+ $this->dbMock->userPropertiesSet->calledWith($user, $out);
+ $this->dbMock->userExists->calledWith($user);
}
}
- public function provideAdditions(): iterable {
- $john = "john.doe@example.com";
- $jane = "jane.doe@example.com";
- return [
- [false, $john, "secret", new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [false, $jane, "superman", new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [true, $john, "secret", new \JKingWeb\Arsse\User\Exception("alreadyExists")],
- [true, $jane, "superman", "superman"],
- [true, $jane, null, "random password"],
- ];
- }
-
- /** @dataProvider provideRemovals */
- public function testRemoveAUser(bool $authorized, string $user, bool $exists, $exp): void {
- $u = new User($this->drv);
- \Phake::when($this->drv)->authorize->thenReturn($authorized);
- \Phake::when($this->drv)->userRemove("john.doe@example.com")->thenReturn(true);
- \Phake::when($this->drv)->userRemove("jane.doe@example.com")->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist"));
- \Phake::when(Arsse::$db)->userExists->thenReturn($exists);
- \Phake::when(Arsse::$db)->userRemove->thenReturn(true);
- if ($exp instanceof Exception) {
- if ($exp instanceof \JKingWeb\Arsse\User\ExceptionAuthz) {
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- } else {
- $this->assertException("doesNotExist", "User");
- }
- }
- try {
- $this->assertSame($exp, $u->remove($user));
- } finally {
- \Phake::verify(Arsse::$db, \Phake::times((int) $authorized))->userExists($user);
- \Phake::verify(Arsse::$db, \Phake::times((int) ($authorized && $exists)))->userRemove($user);
- }
- }
-
- public function provideRemovals(): iterable {
- $john = "john.doe@example.com";
- $jane = "jane.doe@example.com";
- return [
- [false, $john, true, new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [false, $john, false, new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [false, $jane, true, new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [false, $jane, false, new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [true, $john, true, true],
- [true, $john, false, true],
- [true, $jane, true, new \JKingWeb\Arsse\User\Exception("doesNotExist")],
- [true, $jane, false, new \JKingWeb\Arsse\User\Exception("doesNotExist")],
- ];
- }
-
- /** @dataProvider providePasswordChanges */
- public function testChangeAPassword(bool $authorized, string $user, $password, bool $exists, $exp): void {
- $u = new User($this->drv);
- \Phake::when($this->drv)->authorize->thenReturn($authorized);
- \Phake::when($this->drv)->userPasswordSet("john.doe@example.com", $this->anything(), $this->anything())->thenReturnCallback(function($user, $pass, $old) {
- return $pass ?? "random password";
- });
- \Phake::when($this->drv)->userPasswordSet("jane.doe@example.com", $this->anything(), $this->anything())->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist"));
- \Phake::when(Arsse::$db)->userExists->thenReturn($exists);
- if ($exp instanceof Exception) {
- if ($exp instanceof \JKingWeb\Arsse\User\ExceptionAuthz) {
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- } else {
- $this->assertException("doesNotExist", "User");
- }
- $calls = 0;
+ /** @dataProvider providePropertyChanges */
+ public function testSetThePropertiesOfAUserWeDoNotKnow(array $in, $out): void {
+ $user = "john.doe@example.com";
+ if ($out instanceof \Exception) {
+ $u = $this->prepTest();
+ $this->assertException($out);
+ $u->propertiesSet($user, $in);
} else {
- $calls = 1;
- }
- try {
- $this->assertSame($exp, $u->passwordSet($user, $password));
- } finally {
- \Phake::verify(Arsse::$db, \Phake::times($calls))->userExists($user);
- \Phake::verify(Arsse::$db, \Phake::times($exists ? $calls : 0))->userPasswordSet($user, $password ?? "random password", null);
+ $this->dbMock->userExists->returns(false);
+ $this->drv->userPropertiesSet->returns($out);
+ $this->dbMock->userPropertiesSet->returns(true);
+ $u = $this->prepTest();
+ $this->assertSame($out, $u->propertiesSet($user, $in));
+ $this->drv->userPropertiesSet->calledWith($user, $in);
+ $this->dbMock->userPropertiesSet->calledWith($user, $out);
+ $this->dbMock->userExists->calledWith($user);
+ $this->dbMock->userAdd->calledWith($user, null);
}
}
- /** @dataProvider providePasswordChanges */
- public function testChangeAPasswordToARandomPassword(bool $authorized, string $user, $password, bool $exists, $exp): void {
- $u = \Phake::partialMock(User::class, $this->drv);
- \Phake::when($this->drv)->authorize->thenReturn($authorized);
- \Phake::when($this->drv)->userPasswordSet($this->anything(), $this->isNull(), $this->anything())->thenReturn(null);
- \Phake::when($this->drv)->userPasswordSet("john.doe@example.com", $this->logicalNot($this->isNull()), $this->anything())->thenReturnCallback(function($user, $pass, $old) {
- return $pass ?? "random password";
- });
- \Phake::when($this->drv)->userPasswordSet("jane.doe@example.com", $this->logicalNot($this->isNull()), $this->anything())->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist"));
- \Phake::when(Arsse::$db)->userExists->thenReturn($exists);
- if ($exp instanceof Exception) {
- if ($exp instanceof \JKingWeb\Arsse\User\ExceptionAuthz) {
- $this->assertException("notAuthorized", "User", "ExceptionAuthz");
- $calls = 0;
- } else {
- $this->assertException("doesNotExist", "User");
- $calls = 2;
- }
- } else {
- $calls = 4;
- }
- try {
- $pass1 = $u->passwordSet($user, null);
- $pass2 = $u->passwordSet($user, null);
- $this->assertNotEquals($pass1, $pass2);
- } finally {
- \Phake::verify($this->drv, \Phake::times($calls))->userPasswordSet;
- \Phake::verify($u, \Phake::times($calls / 2))->generatePassword;
- \Phake::verify(Arsse::$db, \Phake::times($calls == 4 ? 2 : 0))->userExists($user);
- if ($calls == 4) {
- \Phake::verify(Arsse::$db, \Phake::times($exists ? 1 : 0))->userPasswordSet($user, $pass1, null);
- \Phake::verify(Arsse::$db, \Phake::times($exists ? 1 : 0))->userPasswordSet($user, $pass2, null);
- } else {
- \Phake::verify(Arsse::$db, \Phake::times(0))->userPasswordSet;
- }
- }
- }
-
- public function providePasswordChanges(): iterable {
- $john = "john.doe@example.com";
- $jane = "jane.doe@example.com";
+ public function providePropertyChanges(): iterable {
return [
- [false, $john, "secret", true, new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [false, $jane, "superman", false, new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized")],
- [true, $john, "superman", true, "superman"],
- [true, $john, null, true, "random password"],
- [true, $john, "superman", false, "superman"],
- [true, $john, null, false, "random password"],
- [true, $jane, "secret", true, new \JKingWeb\Arsse\User\Exception("doesNotExist")],
+ [['admin' => true], ['admin' => true]],
+ [['admin' => 2], new ExceptionInput("invalidValue")],
+ [['sort_asc' => 2], new ExceptionInput("invalidValue")],
+ [['tz' => "Etc/UTC"], ['tz' => "Etc/UTC"]],
+ [['tz' => "Etc/blah"], new ExceptionInput("invalidTimezone")],
+ [['tz' => false], new ExceptionInput("invalidValue")],
+ [['lang' => "en-ca"], ['lang' => "en-CA"]],
+ [['lang' => null], ['lang' => null]],
+ [['page_size' => 0], new ExceptionInput("invalidNonZeroInteger")],
];
}
- /** @dataProvider providePasswordClearings */
- public function testClearAPassword(bool $authorized, bool $exists, string $user, $exp): void {
- \Phake::when($this->drv)->authorize->thenReturn($authorized);
- \Phake::when($this->drv)->userPasswordUnset->thenReturn(true);
- \Phake::when($this->drv)->userPasswordUnset("jane.doe@example.net", null)->thenThrow(new \JKingWeb\Arsse\User\Exception("doesNotExist"));
- \Phake::when(Arsse::$db)->userExists->thenReturn($exists);
- $u = new User($this->drv);
+ public function testSetThePropertiesOfAMissingUser(): void {
+ $user = "john.doe@example.com";
+ $in = ['admin' => true];
+ $this->drv->userPropertiesSet->throws(new ExceptionConflict("doesNotExist"));
+ $u = $this->prepTest();
+ $this->assertException("doesNotExist", "User", "ExceptionConflict");
try {
- if ($exp instanceof \JKingWeb\Arsse\AbstractException) {
- $this->assertException($exp);
- $u->passwordUnset($user);
- } else {
- $this->assertSame($exp, $u->passwordUnset($user));
- }
+ $u->propertiesSet($user, $in);
} finally {
- \Phake::verify(Arsse::$db, \Phake::times((int) ($authorized && $exists && is_bool($exp))))->userPasswordSet($user, null);
+ $this->drv->userPropertiesSet->calledWith($user, $in);
}
}
-
- public function providePasswordClearings(): iterable {
- $forbidden = new \JKingWeb\Arsse\User\ExceptionAuthz("notAuthorized");
- $missing = new \JKingWeb\Arsse\User\Exception("doesNotExist");
- return [
- [false, true, "jane.doe@example.com", $forbidden],
- [false, true, "john.doe@example.com", $forbidden],
- [false, true, "jane.doe@example.net", $forbidden],
- [false, false, "jane.doe@example.com", $forbidden],
- [false, false, "john.doe@example.com", $forbidden],
- [false, false, "jane.doe@example.net", $forbidden],
- [true, true, "jane.doe@example.com", true],
- [true, true, "john.doe@example.com", true],
- [true, true, "jane.doe@example.net", $missing],
- [true, false, "jane.doe@example.com", true],
- [true, false, "john.doe@example.com", true],
- [true, false, "jane.doe@example.net", $missing],
- ];
- }
}
diff --git a/tests/docroot/Feed/Caching/200Future.php b/tests/docroot/Feed/Caching/200Future.php
index ef2ae714..ad43e361 100644
--- a/tests/docroot/Feed/Caching/200Future.php
+++ b/tests/docroot/Feed/Caching/200Future.php
@@ -6,7 +6,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
diff --git a/tests/docroot/Feed/Caching/200Multiple.php b/tests/docroot/Feed/Caching/200Multiple.php
index 583b6633..ebbd8a29 100644
--- a/tests/docroot/Feed/Caching/200Multiple.php
+++ b/tests/docroot/Feed/Caching/200Multiple.php
@@ -6,7 +6,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Caching/200None.php b/tests/docroot/Feed/Caching/200None.php
index 562554cf..ebe7721f 100644
--- a/tests/docroot/Feed/Caching/200None.php
+++ b/tests/docroot/Feed/Caching/200None.php
@@ -6,7 +6,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Caching/200Past.php b/tests/docroot/Feed/Caching/200Past.php
index 361d7670..64da54c5 100644
--- a/tests/docroot/Feed/Caching/200Past.php
+++ b/tests/docroot/Feed/Caching/200Past.php
@@ -6,7 +6,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
diff --git a/tests/docroot/Feed/Caching/200PubDateOnly.php b/tests/docroot/Feed/Caching/200PubDateOnly.php
index 5b8df9b0..93ce6370 100644
--- a/tests/docroot/Feed/Caching/200PubDateOnly.php
+++ b/tests/docroot/Feed/Caching/200PubDateOnly.php
@@ -6,7 +6,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Caching/200UpdateDate.php b/tests/docroot/Feed/Caching/200UpdateDate.php
index e7f9a20b..3315d289 100644
--- a/tests/docroot/Feed/Caching/200UpdateDate.php
+++ b/tests/docroot/Feed/Caching/200UpdateDate.php
@@ -6,7 +6,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Deduplication/Hashes-Dates1.php b/tests/docroot/Feed/Deduplication/Hashes-Dates1.php
index 4709e807..8449beed 100644
--- a/tests/docroot/Feed/Deduplication/Hashes-Dates1.php
+++ b/tests/docroot/Feed/Deduplication/Hashes-Dates1.php
@@ -4,7 +4,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Deduplication/Hashes-Dates2.php b/tests/docroot/Feed/Deduplication/Hashes-Dates2.php
index 321d675d..b460c7d8 100644
--- a/tests/docroot/Feed/Deduplication/Hashes-Dates2.php
+++ b/tests/docroot/Feed/Deduplication/Hashes-Dates2.php
@@ -4,7 +4,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Deduplication/Hashes-Dates3.php b/tests/docroot/Feed/Deduplication/Hashes-Dates3.php
index 01d0916d..ce950194 100644
--- a/tests/docroot/Feed/Deduplication/Hashes-Dates3.php
+++ b/tests/docroot/Feed/Deduplication/Hashes-Dates3.php
@@ -4,7 +4,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Deduplication/Hashes.php b/tests/docroot/Feed/Deduplication/Hashes.php
index bc6eaec4..2f2a9677 100644
--- a/tests/docroot/Feed/Deduplication/Hashes.php
+++ b/tests/docroot/Feed/Deduplication/Hashes.php
@@ -4,7 +4,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Deduplication/ID-Dates.php b/tests/docroot/Feed/Deduplication/ID-Dates.php
index f26cfc50..90f70260 100644
--- a/tests/docroot/Feed/Deduplication/ID-Dates.php
+++ b/tests/docroot/Feed/Deduplication/ID-Dates.php
@@ -4,7 +4,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Deduplication/IdenticalHashes.php b/tests/docroot/Feed/Deduplication/IdenticalHashes.php
index 138b7b44..b9e64667 100644
--- a/tests/docroot/Feed/Deduplication/IdenticalHashes.php
+++ b/tests/docroot/Feed/Deduplication/IdenticalHashes.php
@@ -4,7 +4,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
diff --git a/tests/docroot/Feed/Deduplication/Permalink-Dates.php b/tests/docroot/Feed/Deduplication/Permalink-Dates.php
index 304211a8..afb91548 100644
--- a/tests/docroot/Feed/Deduplication/Permalink-Dates.php
+++ b/tests/docroot/Feed/Deduplication/Permalink-Dates.php
@@ -4,29 +4,29 @@
Test feed
- http://example.com/
+ http://localhost:8000/
A basic feed for testing
-
-
http://example.com/1
+ http://localhost:8000/1
Sample article 1
Sun, 18 May 1995 15:21:36 GMT
2002-02-19T15:21:36Z
-
-
http://example.com/1
+ http://localhost:8000/1
Sample article 2
Sun, 19 May 2002 15:21:36 GMT
2002-04-19T15:21:36Z
-
-
http://example.com/1
+ http://localhost:8000/1
Sample article 3
Sun, 18 May 2000 15:21:36 GMT
1999-05-19T15:21:36Z
-
-
http://example.com/2
+ http://localhost:8000/2
Sample article 4
Sun, 18 May 2000 15:21:36 GMT
1999-05-19T15:21:36Z
diff --git a/tests/docroot/Feed/Discovery/Feed.php b/tests/docroot/Feed/Discovery/Feed.php
index a13398ac..bb413510 100644
--- a/tests/docroot/Feed/Discovery/Feed.php
+++ b/tests/docroot/Feed/Discovery/Feed.php
@@ -4,7 +4,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
Example newsfeed title
diff --git a/tests/docroot/Feed/Discovery/Missing.php b/tests/docroot/Feed/Discovery/Missing.php
new file mode 100644
index 00000000..666eb036
--- /dev/null
+++ b/tests/docroot/Feed/Discovery/Missing.php
@@ -0,0 +1,3 @@
+ 404,
+];
diff --git a/tests/docroot/Feed/Discovery/Valid.php b/tests/docroot/Feed/Discovery/Valid.php
index 9f34f716..af7b9c17 100644
--- a/tests/docroot/Feed/Discovery/Valid.php
+++ b/tests/docroot/Feed/Discovery/Valid.php
@@ -4,6 +4,7 @@
Example article
+
MESSAGE_BODY
];
diff --git a/tests/docroot/Feed/Fetching/TooLarge.php b/tests/docroot/Feed/Fetching/TooLarge.php
index 0fef567b..d13f89c6 100644
--- a/tests/docroot/Feed/Fetching/TooLarge.php
+++ b/tests/docroot/Feed/Fetching/TooLarge.php
@@ -9,7 +9,7 @@ return [
Test feed
- http://example.com/
+ http://localhost:8000/
Example newsfeed title
$item
diff --git a/tests/docroot/Feed/Filtering/1.php b/tests/docroot/Feed/Filtering/1.php
new file mode 100644
index 00000000..311ac578
--- /dev/null
+++ b/tests/docroot/Feed/Filtering/1.php
@@ -0,0 +1,61 @@
+ "application/atom+xml",
+ 'content' => <<
+ Example feed title
+ urn:uuid:0fd8f6d8-43df-11e7-8511-9b59a0324eb8
+
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89790
+ A
+ Z
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89791
+ B
+ Y
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89792
+ C
+ X
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89793
+ D
+ W
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89794
+ E
+ V
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89795
+ F
+ U
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89796
+ G
+ T
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89797
+ H
+ S
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89798
+ I
+ R
+
+
+ urn:uuid:6d4c7964-43e1-11e7-92bd-4fed65d89799
+ J
+ Q
+
+
+MESSAGE_BODY
+];
diff --git a/tests/docroot/Feed/Matching/1.php b/tests/docroot/Feed/Matching/1.php
index fdc2d676..ef9dfcd0 100644
--- a/tests/docroot/Feed/Matching/1.php
+++ b/tests/docroot/Feed/Matching/1.php
@@ -4,7 +4,7 @@
Example feed title
urn:uuid:0fd8f6d8-43df-11e7-8511-9b59a0324eb8
-
+
urn:uuid:df329114-43df-11e7-9f23-a938604d62f8
diff --git a/tests/docroot/Feed/Matching/2.php b/tests/docroot/Feed/Matching/2.php
index b5e2d51c..0a4ae553 100644
--- a/tests/docroot/Feed/Matching/2.php
+++ b/tests/docroot/Feed/Matching/2.php
@@ -4,7 +4,7 @@
Example feed title
urn:uuid:0fd8f6d8-43df-11e7-8511-9b59a0324eb8
-
+
urn:uuid:df329114-43df-11e7-9f23-a938604d62f8
diff --git a/tests/docroot/Feed/Matching/3.php b/tests/docroot/Feed/Matching/3.php
index d2c8c0d3..665d5d3a 100644
--- a/tests/docroot/Feed/Matching/3.php
+++ b/tests/docroot/Feed/Matching/3.php
@@ -4,7 +4,7 @@
Example feed title
urn:uuid:0fd8f6d8-43df-11e7-8511-9b59a0324eb8
-
+
urn:uuid:df329114-43df-11e7-9f23-a938604d62f8
diff --git a/tests/docroot/Feed/Matching/4.php b/tests/docroot/Feed/Matching/4.php
index a68c0e05..5cd92493 100644
--- a/tests/docroot/Feed/Matching/4.php
+++ b/tests/docroot/Feed/Matching/4.php
@@ -4,7 +4,7 @@
Example feed title
urn:uuid:0fd8f6d8-43df-11e7-8511-9b59a0324eb8
-
+
urn:uuid:df329114-43df-11e7-9f23-a938604d62f8
diff --git a/tests/docroot/Feed/Matching/5.php b/tests/docroot/Feed/Matching/5.php
index efb5a9b3..de61d767 100644
--- a/tests/docroot/Feed/Matching/5.php
+++ b/tests/docroot/Feed/Matching/5.php
@@ -4,7 +4,7 @@
Example feed title
urn:uuid:0fd8f6d8-43df-11e7-8511-9b59a0324eb8
-
+
urn:uuid:3d5f5154-43e1-11e7-ba11-1dcae392a974
diff --git a/tests/docroot/Feed/NextFetch/1h.php b/tests/docroot/Feed/NextFetch/1h.php
index dd016507..ca9cdac6 100644
--- a/tests/docroot/Feed/NextFetch/1h.php
+++ b/tests/docroot/Feed/NextFetch/1h.php
@@ -4,7 +4,7 @@
Example title
- http://example.com
+ http://localhost:8000/
Example description
-
diff --git a/tests/docroot/Feed/NextFetch/3-36h.php b/tests/docroot/Feed/NextFetch/3-36h.php
index 41d799f8..414d2f08 100644
--- a/tests/docroot/Feed/NextFetch/3-36h.php
+++ b/tests/docroot/Feed/NextFetch/3-36h.php
@@ -4,7 +4,7 @@
Example title
- http://example.com
+ http://localhost:8000/
Example description
-
diff --git a/tests/docroot/Feed/NextFetch/30m.php b/tests/docroot/Feed/NextFetch/30m.php
index a7dce241..397871ac 100644
--- a/tests/docroot/Feed/NextFetch/30m.php
+++ b/tests/docroot/Feed/NextFetch/30m.php
@@ -4,7 +4,7 @@
Example title
- http://example.com
+ http://localhost:8000/
Example description
-
diff --git a/tests/docroot/Feed/NextFetch/36h.php b/tests/docroot/Feed/NextFetch/36h.php
index 359ed9e9..251a456a 100644
--- a/tests/docroot/Feed/NextFetch/36h.php
+++ b/tests/docroot/Feed/NextFetch/36h.php
@@ -4,7 +4,7 @@
Example title
- http://example.com
+ http://localhost:8000/
Example description
-
diff --git a/tests/docroot/Feed/NextFetch/3h.php b/tests/docroot/Feed/NextFetch/3h.php
index e2f5758d..dfdd3272 100644
--- a/tests/docroot/Feed/NextFetch/3h.php
+++ b/tests/docroot/Feed/NextFetch/3h.php
@@ -4,7 +4,7 @@
Example title
- http://example.com
+ http://localhost:8000/
Example description
-
diff --git a/tests/docroot/Feed/NextFetch/Fallback.php b/tests/docroot/Feed/NextFetch/Fallback.php
index 04cc8ac0..d127c3a3 100644
--- a/tests/docroot/Feed/NextFetch/Fallback.php
+++ b/tests/docroot/Feed/NextFetch/Fallback.php
@@ -4,7 +4,7 @@
Example title
- http://example.com
+ http://localhost:8000/
Example description
-
diff --git a/tests/docroot/Feed/Parsing/Valid.php b/tests/docroot/Feed/Parsing/Valid.php
index f56bd66b..ab953fc9 100644
--- a/tests/docroot/Feed/Parsing/Valid.php
+++ b/tests/docroot/Feed/Parsing/Valid.php
@@ -4,7 +4,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
Example newsfeed title
-
diff --git a/tests/docroot/Feed/Parsing/XEEAttack.php b/tests/docroot/Feed/Parsing/XEEAttack.php
index 12c4cbf7..a4fa7fe7 100644
--- a/tests/docroot/Feed/Parsing/XEEAttack.php
+++ b/tests/docroot/Feed/Parsing/XEEAttack.php
@@ -16,30 +16,30 @@
Test feed
- http://example.com/
+ http://localhost:8000/
Example newsfeed title
-
urn:uuid:43fb1908-42ec-11e7-b61b-2b118faca2f2
- http://example.com/1
+ http://localhost:8000/1
urn:uuid:4c8dbc84-42eb-11e7-9f61-6f83db96854f
-
urn:uuid:43fb1908-42ec-11e7-b61b-2b118faca2f2
- http://example.com/1
+ http://localhost:8000/1
-
urn:uuid:43fb1908-42ec-11e7-b61b-2b118faca2f2
-
-
http://example.com/2
+ http://localhost:8000/2
-
Example title
-
Example content
-
+
diff --git a/tests/docroot/Feed/Parsing/XXEAttack.php b/tests/docroot/Feed/Parsing/XXEAttack.php
index 8a38e142..c1c51487 100644
--- a/tests/docroot/Feed/Parsing/XXEAttack.php
+++ b/tests/docroot/Feed/Parsing/XXEAttack.php
@@ -7,30 +7,30 @@
Test feed
- http://example.com/
+ http://localhost:8000/
&xxe;
-
urn:uuid:43fb1908-42ec-11e7-b61b-2b118faca2f2
- http://example.com/1
+ http://localhost:8000/1
urn:uuid:4c8dbc84-42eb-11e7-9f61-6f83db96854f
-
urn:uuid:43fb1908-42ec-11e7-b61b-2b118faca2f2
- http://example.com/1
+ http://localhost:8000/1
-
urn:uuid:43fb1908-42ec-11e7-b61b-2b118faca2f2
-
-
http://example.com/2
+ http://localhost:8000/2
-
Example title
-
Example content
-
+
diff --git a/tests/docroot/Feed/Scraping/Feed.php b/tests/docroot/Feed/Scraping/Feed.php
index 71bf40ec..514dcfd0 100644
--- a/tests/docroot/Feed/Scraping/Feed.php
+++ b/tests/docroot/Feed/Scraping/Feed.php
@@ -4,7 +4,7 @@
Test feed
- http://example.com/
+ http://localhost:8000/
Example newsfeed title
-
diff --git a/tests/docroot/Feed/WithIcon/GIF.php b/tests/docroot/Feed/WithIcon/GIF.php
new file mode 100644
index 00000000..ae3ce225
--- /dev/null
+++ b/tests/docroot/Feed/WithIcon/GIF.php
@@ -0,0 +1,11 @@
+ "application/atom+xml",
+ 'content' => <<
+ /Icon/GIF
+
+ Example title
+
+
+MESSAGE_BODY
+];
diff --git a/tests/docroot/Feed/WithIcon/PNG.php b/tests/docroot/Feed/WithIcon/PNG.php
new file mode 100644
index 00000000..1e946d82
--- /dev/null
+++ b/tests/docroot/Feed/WithIcon/PNG.php
@@ -0,0 +1,11 @@
+ "application/atom+xml",
+ 'content' => <<
+ /Icon/PNG
+
+ Example title
+
+
+MESSAGE_BODY
+];
diff --git a/tests/docroot/Feed/WithIcon/SVG1.php b/tests/docroot/Feed/WithIcon/SVG1.php
new file mode 100644
index 00000000..8bbabdeb
--- /dev/null
+++ b/tests/docroot/Feed/WithIcon/SVG1.php
@@ -0,0 +1,11 @@
+ "application/atom+xml",
+ 'content' => <<
+ /Icon/SVG1
+
+ Example title
+
+
+MESSAGE_BODY
+];
diff --git a/tests/docroot/Feed/WithIcon/SVG2.php b/tests/docroot/Feed/WithIcon/SVG2.php
new file mode 100644
index 00000000..ce36bb76
--- /dev/null
+++ b/tests/docroot/Feed/WithIcon/SVG2.php
@@ -0,0 +1,11 @@
+ "application/atom+xml",
+ 'content' => <<
+ /Icon/SVG2
+
+ Example title
+
+
+MESSAGE_BODY
+];
diff --git a/tests/docroot/Icon/GIF.php b/tests/docroot/Icon/GIF.php
new file mode 100644
index 00000000..50f0b78c
--- /dev/null
+++ b/tests/docroot/Icon/GIF.php
@@ -0,0 +1,4 @@
+ "image/gif",
+ 'content' => base64_decode("R0lGODlhAQABAIABAAAAAP///yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="),
+];
diff --git a/tests/docroot/Icon/PNG.php b/tests/docroot/Icon/PNG.php
new file mode 100644
index 00000000..f5c54d63
--- /dev/null
+++ b/tests/docroot/Icon/PNG.php
@@ -0,0 +1,4 @@
+ "image/png",
+ 'content' => base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMjHxIGmVAAAADUlEQVQYV2NgYGBgAAAABQABijPjAAAAAABJRU5ErkJggg=="),
+];
diff --git a/tests/docroot/Icon/SVG1.php b/tests/docroot/Icon/SVG1.php
new file mode 100644
index 00000000..5c90d375
--- /dev/null
+++ b/tests/docroot/Icon/SVG1.php
@@ -0,0 +1,4 @@
+ "image/svg+xml",
+ 'content' => ' ',
+];
diff --git a/tests/docroot/Icon/SVG2.php b/tests/docroot/Icon/SVG2.php
new file mode 100644
index 00000000..e5260cf3
--- /dev/null
+++ b/tests/docroot/Icon/SVG2.php
@@ -0,0 +1,4 @@
+ "image/svg+xml",
+ 'content' => ' ',
+];
diff --git a/tests/docroot/Import/OPML/BrokenOPML.2.opml b/tests/docroot/Import/OPML/BrokenOPML.2.opml
index ac70153f..691c2bd7 100644
--- a/tests/docroot/Import/OPML/BrokenOPML.2.opml
+++ b/tests/docroot/Import/OPML/BrokenOPML.2.opml
@@ -1,2 +1,2 @@
-
+
diff --git a/tests/docroot/Import/OPML/FeedsOnly.opml b/tests/docroot/Import/OPML/FeedsOnly.opml
index 4e682600..88fab76d 100644
--- a/tests/docroot/Import/OPML/FeedsOnly.opml
+++ b/tests/docroot/Import/OPML/FeedsOnly.opml
@@ -1,10 +1,10 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/tests/docroot/Import/some-feed.php b/tests/docroot/Import/some-feed.php
index eec58567..7f48836f 100644
--- a/tests/docroot/Import/some-feed.php
+++ b/tests/docroot/Import/some-feed.php
@@ -4,7 +4,7 @@
Some feed
- http://example.com/
+ http://localhost:8000/
Just a generic feed
-
diff --git a/tests/docroot/index.php b/tests/docroot/index.php
new file mode 100644
index 00000000..4a6611e6
--- /dev/null
+++ b/tests/docroot/index.php
@@ -0,0 +1,4 @@
+ 204,
+ 'content' => "",
+];
diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php
index 593edf80..f9bad429 100644
--- a/tests/lib/AbstractTest.php
+++ b/tests/lib/AbstractTest.php
@@ -7,6 +7,8 @@ declare(strict_types=1);
namespace JKingWeb\Arsse\Test;
use JKingWeb\Arsse\AbstractException;
+use Eloquent\Phony\Mock\Handle\InstanceHandle;
+use Eloquent\Phony\Phpunit\Phony;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use JKingWeb\Arsse\Exception;
@@ -14,6 +16,7 @@ use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Conf;
use JKingWeb\Arsse\Db\Driver;
use JKingWeb\Arsse\Db\Result;
+use JKingWeb\Arsse\Factory;
use JKingWeb\Arsse\Misc\Date;
use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\Misc\URL;
@@ -29,12 +32,19 @@ use Laminas\Diactoros\Response\XmlResponse;
abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
use \DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
+ protected $objMock;
+ protected $confMock;
+ protected $langMock;
+ protected $dbMock;
+ protected $userMock;
+
public function setUp(): void {
self::clearData();
- }
-
- public function tearDown(): void {
- self::clearData();
+ // create the object factory as a mock
+ $this->objMock = Arsse::$obj = $this->mock(Factory::class);
+ $this->objMock->get->does(function(string $class) {
+ return new $class;
+ });
}
public static function clearData(bool $loadLang = true): void {
@@ -84,6 +94,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
}, $params, array_keys($params)));
}
$url = URL::queryAppend($url, (string) $params);
+ $params = null;
}
// glean the scheme, hostname, and port from the URL
$urlParts = parse_url($url);
@@ -154,6 +165,22 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
return $req;
}
+ public static function assertMatchesRegularExpression(string $pattern, string $string, string $message = ''): void {
+ if (method_exists(parent::class, "assertMatchesRegularExpression")) {
+ parent::assertMatchesRegularExpression($pattern, $string, $message);
+ } else {
+ parent::assertRegExp($pattern, $string, $message);
+ }
+ }
+
+ public static function assertFileDoesNotExist(string $filename, string $message = ''): void {
+ if (method_exists(parent::class, "assertFileDoesNotExist")) {
+ parent::assertFileDoesNotExist($filename, $message);
+ } else {
+ parent::assertFileNotExists($filename, $message);
+ }
+ }
+
public function assertException($msg = "", string $prefix = "", string $type = "Exception"): void {
if (func_num_args()) {
if ($msg instanceof \Exception) {
@@ -182,7 +209,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
protected function assertMessage(MessageInterface $exp, MessageInterface $act, string $text = ''): void {
if ($exp instanceof ResponseInterface) {
$this->assertInstanceOf(ResponseInterface::class, $act, $text);
- $this->assertEquals($exp->getStatusCode(), $act->getStatusCode(), $text);
+ $this->assertSame($exp->getStatusCode(), $act->getStatusCode(), $text);
} elseif ($exp instanceof RequestInterface) {
if ($exp instanceof ServerRequestInterface) {
$this->assertInstanceOf(ServerRequestInterface::class, $act, $text);
@@ -193,12 +220,14 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
$this->assertSame($exp->getRequestTarget(), $act->getRequestTarget(), $text);
}
if ($exp instanceof JsonResponse) {
+ $this->assertInstanceOf(JsonResponse::class, $act, $text);
$this->assertEquals($exp->getPayload(), $act->getPayload(), $text);
$this->assertSame($exp->getPayload(), $act->getPayload(), $text);
} elseif ($exp instanceof XmlResponse) {
+ $this->assertInstanceOf(XmlResponse::class, $act, $text);
$this->assertXmlStringEqualsXmlString((string) $exp->getBody(), (string) $act->getBody(), $text);
} else {
- $this->assertEquals((string) $exp->getBody(), (string) $act->getBody(), $text);
+ $this->assertSame((string) $exp->getBody(), (string) $act->getBody(), $text);
}
$this->assertEquals($exp->getHeaders(), $act->getHeaders(), $text);
}
@@ -232,7 +261,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
}
foreach ($value as $k => $v) {
if (is_array($v)) {
- $value[$k] = $this->v($v);
+ $value[$k] = $this->stringify($v);
} elseif (is_int($v) || is_float($v)) {
$value[$k] = (string) $v;
}
@@ -331,6 +360,14 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
public function assertResult(array $expected, Result $data): void {
$data = $data->getAll();
+ // stringify our expectations if necessary
+ if (static::$stringOutput ?? false) {
+ $expected = $this->stringify($expected);
+ // MySQL is extra-special and mixes strings and integers, so we cast the data, too
+ if ((static::$implementation ?? "") === "MySQL") {
+ $data = $this->stringify($data);
+ }
+ }
$this->assertCount(sizeof($expected), $data, "Number of result rows (".sizeof($data).") differs from number of expected rows (".sizeof($expected).")");
if (sizeof($expected)) {
// make sure the expectations are consistent
@@ -343,12 +380,19 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
}
// filter the result set to contain just the desired keys (we don't care if the result has extra keys)
$rows = [];
+ $keys = array_keys($keys);
foreach ($data as $row) {
- $rows[] = array_intersect_key($row, $keys);
+ $r = [];
+ foreach ($keys as $k) {
+ if (array_key_exists($k, $row)) {
+ $r[$k] = $row[$k];
+ }
+ }
+ $rows[] = $r;
}
// compare the result set to the expectations
foreach ($rows as $row) {
- $this->assertContains($row, $expected, "Result set contains unexpected record.");
+ $this->assertContains($row, $expected, "Result set contains unexpected record.\n".var_export($expected, true));
$found = array_search($row, $expected);
unset($expected[$found]);
}
@@ -359,12 +403,20 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase {
/** Guzzle's exception classes require some fairly complicated construction; this abstracts it all away so that only message and code need be supplied */
protected function mockGuzzleException(string $class, ?string $message = null, ?int $code = null, ?\Throwable $e = null): GuzzleException {
if (is_a($class, RequestException::class, true)) {
- $req = \Phake::mock(RequestInterface::class);
- $res = \Phake::mock(ResponseInterface::class);
- \Phake::when($res)->getStatusCode->thenReturn($code ?? 0);
- return new $class($message ?? "", $req, $res, $e);
+ $req = $this->mock(RequestInterface::class);
+ $res = $this->mock(ResponseInterface::class);
+ $res->getStatusCode->returns($code ?? 0);
+ return new $class($message ?? "", $req->get(), $res->get(), $e);
} else {
return new $class($message ?? "", $code ?? 0, $e);
}
}
+
+ protected function mock(string $class): InstanceHandle {
+ return Phony::mock($class);
+ }
+
+ protected function partialMock(string $class, ...$argument): InstanceHandle {
+ return Phony::partialMock($class, $argument);
+ }
}
diff --git a/tests/lib/DatabaseDrivers/MySQL.php b/tests/lib/DatabaseDrivers/MySQL.php
index f1571a2b..0df43d3f 100644
--- a/tests/lib/DatabaseDrivers/MySQL.php
+++ b/tests/lib/DatabaseDrivers/MySQL.php
@@ -18,9 +18,15 @@ trait MySQL {
protected static $stringOutput = true;
public static function dbInterface() {
- $d = @new \mysqli(Arsse::$conf->dbMySQLHost, Arsse::$conf->dbMySQLUser, Arsse::$conf->dbMySQLPass, Arsse::$conf->dbMySQLDb, Arsse::$conf->dbMySQLPort);
+ if (!class_exists("mysqli")) {
+ return null;
+ }
+ $d = mysqli_init();
+ $d->options(\MYSQLI_OPT_INT_AND_FLOAT_NATIVE, false);
+ $d->options(\MYSQLI_SET_CHARSET_NAME, "utf8mb4");
+ @$d->real_connect(Arsse::$conf->dbMySQLHost, Arsse::$conf->dbMySQLUser, Arsse::$conf->dbMySQLPass, Arsse::$conf->dbMySQLDb, Arsse::$conf->dbMySQLPort);
if ($d->connect_errno) {
- return;
+ return null;
}
$d->set_charset("utf8mb4");
foreach (\JKingWeb\Arsse\Db\MySQL\PDODriver::makeSetupQueries() as $q) {
diff --git a/tests/lib/DatabaseDrivers/PostgreSQL.php b/tests/lib/DatabaseDrivers/PostgreSQL.php
index fb0038cb..edc75493 100644
--- a/tests/lib/DatabaseDrivers/PostgreSQL.php
+++ b/tests/lib/DatabaseDrivers/PostgreSQL.php
@@ -19,7 +19,7 @@ trait PostgreSQL {
public static function dbInterface() {
$connString = \JKingWeb\Arsse\Db\PostgreSQL\Driver::makeConnectionString(false, Arsse::$conf->dbPostgreSQLUser, Arsse::$conf->dbPostgreSQLPass, Arsse::$conf->dbPostgreSQLDb, Arsse::$conf->dbPostgreSQLHost, Arsse::$conf->dbPostgreSQLPort, "");
- if ($d = @pg_connect($connString, \PGSQL_CONNECT_FORCE_NEW)) {
+ if (function_exists("pg_connect") && $d = @pg_connect($connString, \PGSQL_CONNECT_FORCE_NEW)) {
foreach (\JKingWeb\Arsse\Db\PostgreSQL\Driver::makeSetupQueries(Arsse::$conf->dbPostgreSQLSchema) as $q) {
pg_query($d, $q);
}
diff --git a/tests/lib/DatabaseDrivers/PostgreSQLPDO.php b/tests/lib/DatabaseDrivers/PostgreSQLPDO.php
index 58001b6f..116c3b23 100644
--- a/tests/lib/DatabaseDrivers/PostgreSQLPDO.php
+++ b/tests/lib/DatabaseDrivers/PostgreSQLPDO.php
@@ -11,7 +11,7 @@ use JKingWeb\Arsse\Arsse;
trait PostgreSQLPDO {
protected static $implementation = "PDO PostgreSQL";
protected static $backend = "PostgreSQL";
- protected static $dbResultClass = \JKingWeb\Arsse\Db\PDOResult::class;
+ protected static $dbResultClass = \JKingWeb\Arsse\Db\PostgreSQL\PDOResult::class;
protected static $dbStatementClass = \JKingWeb\Arsse\Db\PostgreSQL\PDOStatement::class;
protected static $dbDriverClass = \JKingWeb\Arsse\Db\PostgreSQL\PDODriver::class;
protected static $stringOutput = false;
diff --git a/tests/lib/Lang/Setup.php b/tests/lib/Lang/Setup.php
index e8fc312e..c6fa318a 100644
--- a/tests/lib/Lang/Setup.php
+++ b/tests/lib/Lang/Setup.php
@@ -37,14 +37,16 @@ trait Setup {
// set up a file without read access
chmod($this->path."ru.php", 0000);
// make the test Lang class use the vfs files
- $this->l = \Phake::partialMock(Lang::class, $this->path);
- \Phake::when($this->l)->globFiles->thenReturnCallback(function(string $path): array {
+ $this->l = $this->partialMock(Lang::class, $this->path);
+ $this->l->globFiles->does(function(string $path): array {
return Glob::glob($this->path."*.php");
});
+ $this->l = $this->l->get();
// create a mock Lang object so as not to create a dependency loop
self::clearData(false);
- Arsse::$lang = \Phake::mock(Lang::class);
- \Phake::when(Arsse::$lang)->msg->thenReturn("");
+ Arsse::$lang = $this->mock(Lang::class);
+ Arsse::$lang->msg->returns("");
+ Arsse::$lang = Arsse::$lang->get();
// call the additional setup method if it exists
if (method_exists($this, "setUpSeries")) {
$this->setUpSeries();
@@ -52,9 +54,6 @@ trait Setup {
}
public function tearDown(): void {
- // verify calls to the mock Lang object
- \Phake::verify(Arsse::$lang, \Phake::atLeast(0))->msg($this->isType("string"), $this->anything());
- \Phake::verifyNoOtherInteractions(Arsse::$lang);
// clean up
self::clearData(true);
// call the additional teardiwn method if it exists
diff --git a/tests/phpunit.dist.xml b/tests/phpunit.dist.xml
index dc97a942..1942c9b0 100644
--- a/tests/phpunit.dist.xml
+++ b/tests/phpunit.dist.xml
@@ -45,12 +45,14 @@
cases/Conf/TestConf.php
+ cases/Misc/TestFactory.php
cases/Misc/TestValueInfo.php
cases/Misc/TestDate.php
cases/Misc/TestQuery.php
cases/Misc/TestContext.php
cases/Misc/TestURL.php
cases/Misc/TestHTTP.php
+ cases/Misc/TestRule.php
cases/User/TestInternal.php
@@ -112,10 +114,18 @@
cases/REST/TestREST.php
+
+ cases/REST/Miniflux/TestErrorResponse.php
+ cases/REST/Miniflux/TestStatus.php
+ cases/REST/Miniflux/TestV1.php
+ cases/REST/Miniflux/TestToken.php
+ cases/REST/Miniflux/PDO/TestV1.php
+ cases/REST/Miniflux/PDO/TestToken.php
+
cases/REST/NextcloudNews/TestVersions.php
cases/REST/NextcloudNews/TestV1_2.php
- cases/REST/NextcloudNews/PDO/TestV1_2.php
+ cases/REST/NextcloudNews/PDO/TestV1_2.php
cases/REST/TinyTinyRSS/TestSearch.php
@@ -135,6 +145,7 @@
cases/Service/TestService.php
cases/Service/TestSerial.php
cases/Service/TestSubprocess.php
+ cases/Service/TestDaemon.php
cases/CLI/TestCLI.php
cases/TestArsse.php
diff --git a/tests/server.php b/tests/server.php
index 2d738c95..2e7d8d47 100644
--- a/tests/server.php
+++ b/tests/server.php
@@ -41,6 +41,9 @@ $defaults = [ // default values for response
];
$url = explode("?", $_SERVER['REQUEST_URI'])[0];
+if ($url === "/") {
+ $url = "/index";
+}
$base = BASE."tests".\DIRECTORY_SEPARATOR."docroot";
$test = $base.str_replace("/", \DIRECTORY_SEPARATOR, $url).".php";
if (!file_exists($test)) {
diff --git a/vendor-bin/csfixer/composer.lock b/vendor-bin/csfixer/composer.lock
index dc5ddc50..e6893fd6 100644
--- a/vendor-bin/csfixer/composer.lock
+++ b/vendor-bin/csfixer/composer.lock
@@ -9,28 +9,29 @@
"packages-dev": [
{
"name": "composer/semver",
- "version": "1.7.1",
+ "version": "3.2.5",
"source": {
"type": "git",
"url": "https://github.com/composer/semver.git",
- "reference": "38276325bd896f90dfcfe30029aa5db40df387a7"
+ "reference": "31f3ea725711245195f62e54ffa402d8ef2fdba9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/composer/semver/zipball/38276325bd896f90dfcfe30029aa5db40df387a7",
- "reference": "38276325bd896f90dfcfe30029aa5db40df387a7",
+ "url": "https://api.github.com/repos/composer/semver/zipball/31f3ea725711245195f62e54ffa402d8ef2fdba9",
+ "reference": "31f3ea725711245195f62e54ffa402d8ef2fdba9",
"shasum": ""
},
"require": {
- "php": "^5.3.2 || ^7.0"
+ "php": "^5.3.2 || ^7.0 || ^8.0"
},
"require-dev": {
- "phpunit/phpunit": "^4.5 || ^5.0.5"
+ "phpstan/phpstan": "^0.12.54",
+ "symfony/phpunit-bridge": "^4.2 || ^5"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.x-dev"
+ "dev-main": "3.x-dev"
}
},
"autoload": {
@@ -66,6 +67,11 @@
"validation",
"versioning"
],
+ "support": {
+ "irc": "irc://irc.freenode.org/composer",
+ "issues": "https://github.com/composer/semver/issues",
+ "source": "https://github.com/composer/semver/tree/3.2.5"
+ },
"funding": [
{
"url": "https://packagist.com",
@@ -80,20 +86,20 @@
"type": "tidelift"
}
],
- "time": "2020-09-27T13:13:07+00:00"
+ "time": "2021-05-24T12:41:47+00:00"
},
{
"name": "composer/xdebug-handler",
- "version": "1.4.4",
+ "version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/composer/xdebug-handler.git",
- "reference": "6e076a124f7ee146f2487554a94b6a19a74887ba"
+ "reference": "964adcdd3a28bf9ed5d9ac6450064e0d71ed7496"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6e076a124f7ee146f2487554a94b6a19a74887ba",
- "reference": "6e076a124f7ee146f2487554a94b6a19a74887ba",
+ "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/964adcdd3a28bf9ed5d9ac6450064e0d71ed7496",
+ "reference": "964adcdd3a28bf9ed5d9ac6450064e0d71ed7496",
"shasum": ""
},
"require": {
@@ -101,7 +107,8 @@
"psr/log": "^1.0"
},
"require-dev": {
- "phpunit/phpunit": "^4.8.35 || ^5.7 || 6.5 - 8"
+ "phpstan/phpstan": "^0.12.55",
+ "symfony/phpunit-bridge": "^4.2 || ^5"
},
"type": "library",
"autoload": {
@@ -124,6 +131,11 @@
"Xdebug",
"performance"
],
+ "support": {
+ "irc": "irc://irc.freenode.org/composer",
+ "issues": "https://github.com/composer/xdebug-handler/issues",
+ "source": "https://github.com/composer/xdebug-handler/tree/2.0.1"
+ },
"funding": [
{
"url": "https://packagist.com",
@@ -138,39 +150,36 @@
"type": "tidelift"
}
],
- "time": "2020-10-24T12:39:10+00:00"
+ "time": "2021-05-05T19:37:51+00:00"
},
{
"name": "doctrine/annotations",
- "version": "1.11.0",
+ "version": "1.13.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine/annotations.git",
- "reference": "88fb6fb1dae011de24dd6b632811c1ff5c2928f5"
+ "reference": "e6e7b7d5b45a2f2abc5460cc6396480b2b1d321f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/annotations/zipball/88fb6fb1dae011de24dd6b632811c1ff5c2928f5",
- "reference": "88fb6fb1dae011de24dd6b632811c1ff5c2928f5",
+ "url": "https://api.github.com/repos/doctrine/annotations/zipball/e6e7b7d5b45a2f2abc5460cc6396480b2b1d321f",
+ "reference": "e6e7b7d5b45a2f2abc5460cc6396480b2b1d321f",
"shasum": ""
},
"require": {
"doctrine/lexer": "1.*",
"ext-tokenizer": "*",
- "php": "^7.1 || ^8.0"
+ "php": "^7.1 || ^8.0",
+ "psr/cache": "^1 || ^2 || ^3"
},
"require-dev": {
- "doctrine/cache": "1.*",
+ "doctrine/cache": "^1.11 || ^2.0",
"doctrine/coding-standard": "^6.0 || ^8.1",
"phpstan/phpstan": "^0.12.20",
- "phpunit/phpunit": "^7.5 || ^9.1.5"
+ "phpunit/phpunit": "^7.5 || ^8.0 || ^9.1.5",
+ "symfony/cache": "^4.4 || ^5.2"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.11.x-dev"
- }
- },
"autoload": {
"psr-4": {
"Doctrine\\Common\\Annotations\\": "lib/Doctrine/Common/Annotations"
@@ -209,7 +218,11 @@
"docblock",
"parser"
],
- "time": "2020-10-17T22:05:33+00:00"
+ "support": {
+ "issues": "https://github.com/doctrine/annotations/issues",
+ "source": "https://github.com/doctrine/annotations/tree/1.13.1"
+ },
+ "time": "2021-05-16T18:07:53+00:00"
},
{
"name": "doctrine/lexer",
@@ -271,6 +284,10 @@
"parser",
"php"
],
+ "support": {
+ "issues": "https://github.com/doctrine/lexer/issues",
+ "source": "https://github.com/doctrine/lexer/tree/1.2.1"
+ },
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
@@ -289,27 +306,27 @@
},
{
"name": "friendsofphp/php-cs-fixer",
- "version": "v2.16.4",
+ "version": "v2.19.0",
"source": {
"type": "git",
"url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git",
- "reference": "1023c3458137ab052f6ff1e09621a721bfdeca13"
+ "reference": "d5b8a9d852b292c2f8a035200fa6844b1f82300b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/1023c3458137ab052f6ff1e09621a721bfdeca13",
- "reference": "1023c3458137ab052f6ff1e09621a721bfdeca13",
+ "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/d5b8a9d852b292c2f8a035200fa6844b1f82300b",
+ "reference": "d5b8a9d852b292c2f8a035200fa6844b1f82300b",
"shasum": ""
},
"require": {
- "composer/semver": "^1.4",
- "composer/xdebug-handler": "^1.2",
+ "composer/semver": "^1.4 || ^2.0 || ^3.0",
+ "composer/xdebug-handler": "^1.2 || ^2.0",
"doctrine/annotations": "^1.2",
"ext-json": "*",
"ext-tokenizer": "*",
- "php": "^5.6 || ^7.0",
+ "php": "^5.6 || ^7.0 || ^8.0",
"php-cs-fixer/diff": "^1.3",
- "symfony/console": "^3.4.17 || ^4.1.6 || ^5.0",
+ "symfony/console": "^3.4.43 || ^4.1.6 || ^5.0",
"symfony/event-dispatcher": "^3.0 || ^4.0 || ^5.0",
"symfony/filesystem": "^3.0 || ^4.0 || ^5.0",
"symfony/finder": "^3.0 || ^4.0 || ^5.0",
@@ -320,17 +337,19 @@
"symfony/stopwatch": "^3.0 || ^4.0 || ^5.0"
},
"require-dev": {
- "johnkary/phpunit-speedtrap": "^1.1 || ^2.0 || ^3.0",
"justinrainbow/json-schema": "^5.0",
- "keradus/cli-executor": "^1.2",
+ "keradus/cli-executor": "^1.4",
"mikey179/vfsstream": "^1.6",
- "php-coveralls/php-coveralls": "^2.1",
+ "php-coveralls/php-coveralls": "^2.4.2",
"php-cs-fixer/accessible-object": "^1.0",
- "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.1",
- "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.1",
- "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.1",
- "phpunitgoodpractices/traits": "^1.8",
- "symfony/phpunit-bridge": "^5.1",
+ "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.2",
+ "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.2.1",
+ "phpspec/prophecy-phpunit": "^1.1 || ^2.0",
+ "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.13 || ^9.5",
+ "phpunitgoodpractices/polyfill": "^1.5",
+ "phpunitgoodpractices/traits": "^1.9.1",
+ "sanmai/phpunit-legacy-adapter": "^6.4 || ^8.2.1",
+ "symfony/phpunit-bridge": "^5.2.1",
"symfony/yaml": "^3.0 || ^4.0 || ^5.0"
},
"suggest": {
@@ -344,6 +363,11 @@
"php-cs-fixer"
],
"type": "application",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.19-dev"
+ }
+ },
"autoload": {
"psr-4": {
"PhpCsFixer\\": "src/"
@@ -358,6 +382,7 @@
"tests/Test/IntegrationCaseFactoryInterface.php",
"tests/Test/InternalIntegrationCaseFactory.php",
"tests/Test/IsIdenticalConstraint.php",
+ "tests/Test/TokensWithObservedTransformers.php",
"tests/TestCase.php"
]
},
@@ -376,13 +401,17 @@
}
],
"description": "A tool to automatically fix PHP code style",
+ "support": {
+ "issues": "https://github.com/FriendsOfPHP/PHP-CS-Fixer/issues",
+ "source": "https://github.com/FriendsOfPHP/PHP-CS-Fixer/tree/v2.19.0"
+ },
"funding": [
{
"url": "https://github.com/keradus",
"type": "github"
}
],
- "time": "2020-06-27T23:57:46+00:00"
+ "time": "2021-05-03T21:43:24+00:00"
},
{
"name": "php-cs-fixer/diff",
@@ -433,20 +462,24 @@
"keywords": [
"diff"
],
+ "support": {
+ "issues": "https://github.com/PHP-CS-Fixer/diff/issues",
+ "source": "https://github.com/PHP-CS-Fixer/diff/tree/v1.3.1"
+ },
"time": "2020-10-14T08:39:05+00:00"
},
{
- "name": "psr/container",
- "version": "1.0.0",
+ "name": "psr/cache",
+ "version": "1.0.1",
"source": {
"type": "git",
- "url": "https://github.com/php-fig/container.git",
- "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f"
+ "url": "https://github.com/php-fig/cache.git",
+ "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-fig/container/zipball/b7ce3b176482dbbc1245ebf52b181af44c2cf55f",
- "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f",
+ "url": "https://api.github.com/repos/php-fig/cache/zipball/d11b50ad223250cf17b86e38383413f5a6764bf8",
+ "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8",
"shasum": ""
},
"require": {
@@ -460,7 +493,7 @@
},
"autoload": {
"psr-4": {
- "Psr\\Container\\": "src/"
+ "Psr\\Cache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -473,6 +506,50 @@
"homepage": "http://www.php-fig.org/"
}
],
+ "description": "Common interface for caching libraries",
+ "keywords": [
+ "cache",
+ "psr",
+ "psr-6"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/cache/tree/master"
+ },
+ "time": "2016-08-06T20:24:11+00:00"
+ },
+ {
+ "name": "psr/container",
+ "version": "1.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/container.git",
+ "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/container/zipball/8622567409010282b7aeebe4bb841fe98b58dcaf",
+ "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Psr\\Container\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
"description": "Common Container Interface (PHP FIG PSR-11)",
"homepage": "https://github.com/php-fig/container",
"keywords": [
@@ -482,7 +559,11 @@
"container-interop",
"psr"
],
- "time": "2017-02-14T16:28:37+00:00"
+ "support": {
+ "issues": "https://github.com/php-fig/container/issues",
+ "source": "https://github.com/php-fig/container/tree/1.1.1"
+ },
+ "time": "2021-03-05T17:36:06+00:00"
},
{
"name": "psr/event-dispatcher",
@@ -528,20 +609,24 @@
"psr",
"psr-14"
],
+ "support": {
+ "issues": "https://github.com/php-fig/event-dispatcher/issues",
+ "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
+ },
"time": "2019-01-08T18:20:26+00:00"
},
{
"name": "psr/log",
- "version": "1.1.3",
+ "version": "1.1.4",
"source": {
"type": "git",
"url": "https://github.com/php-fig/log.git",
- "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc"
+ "reference": "d49695b909c3b7628b6289db5479a1c204601f11"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc",
- "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11",
+ "reference": "d49695b909c3b7628b6289db5479a1c204601f11",
"shasum": ""
},
"require": {
@@ -565,7 +650,7 @@
"authors": [
{
"name": "PHP-FIG",
- "homepage": "http://www.php-fig.org/"
+ "homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for logging libraries",
@@ -575,24 +660,28 @@
"psr",
"psr-3"
],
- "time": "2020-03-23T09:12:05+00:00"
+ "support": {
+ "source": "https://github.com/php-fig/log/tree/1.1.4"
+ },
+ "time": "2021-05-03T11:20:27+00:00"
},
{
"name": "symfony/console",
- "version": "v5.1.7",
+ "version": "v5.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "ae789a8a2ad189ce7e8216942cdb9b77319f5eb8"
+ "reference": "649730483885ff2ca99ca0560ef0e5f6b03f2ac1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/ae789a8a2ad189ce7e8216942cdb9b77319f5eb8",
- "reference": "ae789a8a2ad189ce7e8216942cdb9b77319f5eb8",
+ "url": "https://api.github.com/repos/symfony/console/zipball/649730483885ff2ca99ca0560ef0e5f6b03f2ac1",
+ "reference": "649730483885ff2ca99ca0560ef0e5f6b03f2ac1",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
+ "symfony/deprecation-contracts": "^2.1",
"symfony/polyfill-mbstring": "~1.0",
"symfony/polyfill-php73": "^1.8",
"symfony/polyfill-php80": "^1.15",
@@ -625,11 +714,6 @@
"symfony/process": ""
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Console\\": ""
@@ -652,8 +736,17 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Console Component",
+ "description": "Eases the creation of beautiful and testable command line interfaces",
"homepage": "https://symfony.com",
+ "keywords": [
+ "cli",
+ "command line",
+ "console",
+ "terminal"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/console/tree/v5.3.2"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -668,20 +761,20 @@
"type": "tidelift"
}
],
- "time": "2020-10-07T15:23:00+00:00"
+ "time": "2021-06-12T09:42:48+00:00"
},
{
"name": "symfony/deprecation-contracts",
- "version": "v2.2.0",
+ "version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "5fa56b4074d1ae755beb55617ddafe6f5d78f665"
+ "reference": "5f38c8804a9e97d23e0c8d63341088cd8a22d627"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/5fa56b4074d1ae755beb55617ddafe6f5d78f665",
- "reference": "5fa56b4074d1ae755beb55617ddafe6f5d78f665",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/5f38c8804a9e97d23e0c8d63341088cd8a22d627",
+ "reference": "5f38c8804a9e97d23e0c8d63341088cd8a22d627",
"shasum": ""
},
"require": {
@@ -690,7 +783,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.2-dev"
+ "dev-main": "2.4-dev"
},
"thanks": {
"name": "symfony/contracts",
@@ -718,6 +811,9 @@
],
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v2.4.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -732,20 +828,20 @@
"type": "tidelift"
}
],
- "time": "2020-09-07T11:33:47+00:00"
+ "time": "2021-03-23T23:28:01+00:00"
},
{
"name": "symfony/event-dispatcher",
- "version": "v5.1.7",
+ "version": "v5.3.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
- "reference": "d5de97d6af175a9e8131c546db054ca32842dd0f"
+ "reference": "67a5f354afa8e2f231081b3fa11a5912f933c3ce"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d5de97d6af175a9e8131c546db054ca32842dd0f",
- "reference": "d5de97d6af175a9e8131c546db054ca32842dd0f",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/67a5f354afa8e2f231081b3fa11a5912f933c3ce",
+ "reference": "67a5f354afa8e2f231081b3fa11a5912f933c3ce",
"shasum": ""
},
"require": {
@@ -776,11 +872,6 @@
"symfony/http-kernel": ""
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\EventDispatcher\\": ""
@@ -803,8 +894,11 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony EventDispatcher Component",
+ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/event-dispatcher/tree/v5.3.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -819,20 +913,20 @@
"type": "tidelift"
}
],
- "time": "2020-09-18T14:27:32+00:00"
+ "time": "2021-05-26T17:43:10+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
- "version": "v2.2.0",
+ "version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher-contracts.git",
- "reference": "0ba7d54483095a198fa51781bc608d17e84dffa2"
+ "reference": "69fee1ad2332a7cbab3aca13591953da9cdb7a11"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/0ba7d54483095a198fa51781bc608d17e84dffa2",
- "reference": "0ba7d54483095a198fa51781bc608d17e84dffa2",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/69fee1ad2332a7cbab3aca13591953da9cdb7a11",
+ "reference": "69fee1ad2332a7cbab3aca13591953da9cdb7a11",
"shasum": ""
},
"require": {
@@ -845,7 +939,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.2-dev"
+ "dev-main": "2.4-dev"
},
"thanks": {
"name": "symfony/contracts",
@@ -881,6 +975,9 @@
"interoperability",
"standards"
],
+ "support": {
+ "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v2.4.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -895,20 +992,20 @@
"type": "tidelift"
}
],
- "time": "2020-09-07T11:33:47+00:00"
+ "time": "2021-03-23T23:28:01+00:00"
},
{
"name": "symfony/filesystem",
- "version": "v5.1.7",
+ "version": "v5.3.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
- "reference": "1a8697545a8d87b9f2f6b1d32414199cc5e20aae"
+ "reference": "19b71c8f313b411172dd5f470fd61f24466d79a9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/1a8697545a8d87b9f2f6b1d32414199cc5e20aae",
- "reference": "1a8697545a8d87b9f2f6b1d32414199cc5e20aae",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/19b71c8f313b411172dd5f470fd61f24466d79a9",
+ "reference": "19b71c8f313b411172dd5f470fd61f24466d79a9",
"shasum": ""
},
"require": {
@@ -916,11 +1013,6 @@
"symfony/polyfill-ctype": "~1.8"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Filesystem\\": ""
@@ -943,8 +1035,11 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Filesystem Component",
+ "description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/filesystem/tree/v5.3.3"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -959,31 +1054,26 @@
"type": "tidelift"
}
],
- "time": "2020-09-27T14:02:37+00:00"
+ "time": "2021-06-30T07:27:52+00:00"
},
{
"name": "symfony/finder",
- "version": "v5.1.7",
+ "version": "v5.3.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "2c3ba7ad6884e6c4451ce2340e2dc23f6fa3e0d8"
+ "reference": "0ae3f047bed4edff6fd35b26a9a6bfdc92c953c6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/2c3ba7ad6884e6c4451ce2340e2dc23f6fa3e0d8",
- "reference": "2c3ba7ad6884e6c4451ce2340e2dc23f6fa3e0d8",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/0ae3f047bed4edff6fd35b26a9a6bfdc92c953c6",
+ "reference": "0ae3f047bed4edff6fd35b26a9a6bfdc92c953c6",
"shasum": ""
},
"require": {
"php": ">=7.2.5"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Finder\\": ""
@@ -1006,8 +1096,11 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Finder Component",
+ "description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/finder/tree/v5.3.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1022,33 +1115,29 @@
"type": "tidelift"
}
],
- "time": "2020-09-02T16:23:27+00:00"
+ "time": "2021-05-26T12:52:38+00:00"
},
{
"name": "symfony/options-resolver",
- "version": "v5.1.7",
+ "version": "v5.3.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
- "reference": "4c7e155bf7d93ea4ba3824d5a14476694a5278dd"
+ "reference": "162e886ca035869866d233a2bfef70cc28f9bbe5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/options-resolver/zipball/4c7e155bf7d93ea4ba3824d5a14476694a5278dd",
- "reference": "4c7e155bf7d93ea4ba3824d5a14476694a5278dd",
+ "url": "https://api.github.com/repos/symfony/options-resolver/zipball/162e886ca035869866d233a2bfef70cc28f9bbe5",
+ "reference": "162e886ca035869866d233a2bfef70cc28f9bbe5",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
"symfony/deprecation-contracts": "^2.1",
+ "symfony/polyfill-php73": "~1.0",
"symfony/polyfill-php80": "^1.15"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\OptionsResolver\\": ""
@@ -1071,13 +1160,16 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony OptionsResolver Component",
+ "description": "Provides an improved replacement for the array_replace PHP function",
"homepage": "https://symfony.com",
"keywords": [
"config",
"configuration",
"options"
],
+ "support": {
+ "source": "https://github.com/symfony/options-resolver/tree/v5.3.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1092,20 +1184,20 @@
"type": "tidelift"
}
],
- "time": "2020-09-27T03:44:28+00:00"
+ "time": "2021-05-26T17:43:10+00:00"
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
- "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41"
+ "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f4ba089a5b6366e453971d3aad5fe8e897b37f41",
- "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/46cd95797e9df938fdd2b03693b5fca5e64b01ce",
+ "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce",
"shasum": ""
},
"require": {
@@ -1117,7 +1209,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1154,6 +1246,9 @@
"polyfill",
"portable"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1168,20 +1263,20 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-02-19T12:13:01+00:00"
},
{
"name": "symfony/polyfill-intl-grapheme",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
- "reference": "c7cf3f858ec7d70b89559d6e6eb1f7c2517d479c"
+ "reference": "24b72c6baa32c746a4d0840147c9715e42bb68ab"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/c7cf3f858ec7d70b89559d6e6eb1f7c2517d479c",
- "reference": "c7cf3f858ec7d70b89559d6e6eb1f7c2517d479c",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/24b72c6baa32c746a4d0840147c9715e42bb68ab",
+ "reference": "24b72c6baa32c746a4d0840147c9715e42bb68ab",
"shasum": ""
},
"require": {
@@ -1193,7 +1288,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1232,6 +1327,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1246,20 +1344,20 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-05-27T09:17:38+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
- "reference": "727d1096295d807c309fb01a851577302394c897"
+ "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/727d1096295d807c309fb01a851577302394c897",
- "reference": "727d1096295d807c309fb01a851577302394c897",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8590a5f561694770bdcd3f9b5c69dde6945028e8",
+ "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8",
"shasum": ""
},
"require": {
@@ -1271,7 +1369,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1313,6 +1411,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1327,20 +1428,20 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-02-19T12:13:01+00:00"
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "39d483bdf39be819deabf04ec872eb0b2410b531"
+ "reference": "2df51500adbaebdc4c38dea4c89a2e131c45c8a1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/39d483bdf39be819deabf04ec872eb0b2410b531",
- "reference": "39d483bdf39be819deabf04ec872eb0b2410b531",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/2df51500adbaebdc4c38dea4c89a2e131c45c8a1",
+ "reference": "2df51500adbaebdc4c38dea4c89a2e131c45c8a1",
"shasum": ""
},
"require": {
@@ -1352,7 +1453,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1390,6 +1491,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1404,7 +1508,7 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-05-27T09:27:20+00:00"
},
{
"name": "symfony/polyfill-php70",
@@ -1455,6 +1559,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php70/tree/v1.20.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1473,16 +1580,16 @@
},
{
"name": "symfony/polyfill-php72",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php72.git",
- "reference": "cede45fcdfabdd6043b3592e83678e42ec69e930"
+ "reference": "9a142215a36a3888e30d0a9eeea9766764e96976"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/cede45fcdfabdd6043b3592e83678e42ec69e930",
- "reference": "cede45fcdfabdd6043b3592e83678e42ec69e930",
+ "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/9a142215a36a3888e30d0a9eeea9766764e96976",
+ "reference": "9a142215a36a3888e30d0a9eeea9766764e96976",
"shasum": ""
},
"require": {
@@ -1491,7 +1598,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1528,6 +1635,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php72/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1542,20 +1652,20 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-05-27T09:17:38+00:00"
},
{
"name": "symfony/polyfill-php73",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php73.git",
- "reference": "8ff431c517be11c78c48a39a66d37431e26a6bed"
+ "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/8ff431c517be11c78c48a39a66d37431e26a6bed",
- "reference": "8ff431c517be11c78c48a39a66d37431e26a6bed",
+ "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/fba8933c384d6476ab14fb7b8526e5287ca7e010",
+ "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010",
"shasum": ""
},
"require": {
@@ -1564,7 +1674,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1604,6 +1714,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php73/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1618,20 +1731,20 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-02-19T12:13:01+00:00"
},
{
"name": "symfony/polyfill-php80",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
- "reference": "e70aa8b064c5b72d3df2abd5ab1e90464ad009de"
+ "reference": "eca0bf41ed421bed1b57c4958bab16aa86b757d0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/e70aa8b064c5b72d3df2abd5ab1e90464ad009de",
- "reference": "e70aa8b064c5b72d3df2abd5ab1e90464ad009de",
+ "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/eca0bf41ed421bed1b57c4958bab16aa86b757d0",
+ "reference": "eca0bf41ed421bed1b57c4958bab16aa86b757d0",
"shasum": ""
},
"require": {
@@ -1640,7 +1753,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1684,6 +1797,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php80/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1698,20 +1814,20 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-02-19T12:13:01+00:00"
},
{
"name": "symfony/process",
- "version": "v5.1.7",
+ "version": "v5.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "d3a2e64866169586502f0cd9cab69135ad12cee9"
+ "reference": "714b47f9196de61a196d86c4bad5f09201b307df"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/d3a2e64866169586502f0cd9cab69135ad12cee9",
- "reference": "d3a2e64866169586502f0cd9cab69135ad12cee9",
+ "url": "https://api.github.com/repos/symfony/process/zipball/714b47f9196de61a196d86c4bad5f09201b307df",
+ "reference": "714b47f9196de61a196d86c4bad5f09201b307df",
"shasum": ""
},
"require": {
@@ -1719,11 +1835,6 @@
"symfony/polyfill-php80": "^1.15"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Process\\": ""
@@ -1746,8 +1857,11 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Process Component",
+ "description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/process/tree/v5.3.2"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1762,25 +1876,25 @@
"type": "tidelift"
}
],
- "time": "2020-09-02T16:23:27+00:00"
+ "time": "2021-06-12T10:15:01+00:00"
},
{
"name": "symfony/service-contracts",
- "version": "v2.2.0",
+ "version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
- "reference": "d15da7ba4957ffb8f1747218be9e1a121fd298a1"
+ "reference": "f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d15da7ba4957ffb8f1747218be9e1a121fd298a1",
- "reference": "d15da7ba4957ffb8f1747218be9e1a121fd298a1",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb",
+ "reference": "f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
- "psr/container": "^1.0"
+ "psr/container": "^1.1"
},
"suggest": {
"symfony/service-implementation": ""
@@ -1788,7 +1902,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.2-dev"
+ "dev-main": "2.4-dev"
},
"thanks": {
"name": "symfony/contracts",
@@ -1824,6 +1938,9 @@
"interoperability",
"standards"
],
+ "support": {
+ "source": "https://github.com/symfony/service-contracts/tree/v2.4.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1838,20 +1955,20 @@
"type": "tidelift"
}
],
- "time": "2020-09-07T11:33:47+00:00"
+ "time": "2021-04-01T10:43:52+00:00"
},
{
"name": "symfony/stopwatch",
- "version": "v5.1.7",
+ "version": "v5.3.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/stopwatch.git",
- "reference": "0f7c58cf81dbb5dd67d423a89d577524a2ec0323"
+ "reference": "313d02f59d6543311865007e5ff4ace05b35ee65"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/stopwatch/zipball/0f7c58cf81dbb5dd67d423a89d577524a2ec0323",
- "reference": "0f7c58cf81dbb5dd67d423a89d577524a2ec0323",
+ "url": "https://api.github.com/repos/symfony/stopwatch/zipball/313d02f59d6543311865007e5ff4ace05b35ee65",
+ "reference": "313d02f59d6543311865007e5ff4ace05b35ee65",
"shasum": ""
},
"require": {
@@ -1859,11 +1976,6 @@
"symfony/service-contracts": "^1.0|^2"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Stopwatch\\": ""
@@ -1886,8 +1998,11 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Stopwatch Component",
+ "description": "Provides a way to profile code",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/stopwatch/tree/v5.3.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1902,20 +2017,20 @@
"type": "tidelift"
}
],
- "time": "2020-05-20T17:43:50+00:00"
+ "time": "2021-05-26T17:43:10+00:00"
},
{
"name": "symfony/string",
- "version": "v5.1.7",
+ "version": "v5.3.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
- "reference": "4a9afe9d07bac506f75bcee8ed3ce76da5a9343e"
+ "reference": "bd53358e3eccec6a670b5f33ab680d8dbe1d4ae1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/4a9afe9d07bac506f75bcee8ed3ce76da5a9343e",
- "reference": "4a9afe9d07bac506f75bcee8ed3ce76da5a9343e",
+ "url": "https://api.github.com/repos/symfony/string/zipball/bd53358e3eccec6a670b5f33ab680d8dbe1d4ae1",
+ "reference": "bd53358e3eccec6a670b5f33ab680d8dbe1d4ae1",
"shasum": ""
},
"require": {
@@ -1933,11 +2048,6 @@
"symfony/var-exporter": "^4.4|^5.0"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\String\\": ""
@@ -1963,7 +2073,7 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony String component",
+ "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
"homepage": "https://symfony.com",
"keywords": [
"grapheme",
@@ -1973,6 +2083,9 @@
"utf-8",
"utf8"
],
+ "support": {
+ "source": "https://github.com/symfony/string/tree/v5.3.3"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1987,7 +2100,7 @@
"type": "tidelift"
}
],
- "time": "2020-09-15T12:23:47+00:00"
+ "time": "2021-06-27T11:44:38+00:00"
}
],
"aliases": [],
@@ -1997,5 +2110,5 @@
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
- "plugin-api-version": "1.1.0"
+ "plugin-api-version": "2.0.0"
}
diff --git a/vendor-bin/daux/composer.json b/vendor-bin/daux/composer.json
index 75452ad2..572a4314 100644
--- a/vendor-bin/daux/composer.json
+++ b/vendor-bin/daux/composer.json
@@ -1,5 +1,5 @@
{
"require-dev": {
- "daux/daux.io": "^0.11"
+ "daux/daux.io": "0.11 - 0.15.1"
}
}
diff --git a/vendor-bin/daux/composer.lock b/vendor-bin/daux/composer.lock
index 14ef3c4f..7d3c2bd2 100644
--- a/vendor-bin/daux/composer.lock
+++ b/vendor-bin/daux/composer.lock
@@ -4,43 +4,42 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "c1f427736a854f73400b9e527724a254",
+ "content-hash": "96485876c7a0e21fe5da189394966ef1",
"packages": [],
"packages-dev": [
{
"name": "daux/daux.io",
- "version": "0.11.1",
+ "version": "0.15.1",
"source": {
"type": "git",
"url": "https://github.com/dauxio/daux.io.git",
- "reference": "e796fad8627b7a2c95d1caf80e8e7b19969133e9"
+ "reference": "0f03a24aef0e9f2544b08981e20a1c79dd8fbda0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/dauxio/daux.io/zipball/e796fad8627b7a2c95d1caf80e8e7b19969133e9",
- "reference": "e796fad8627b7a2c95d1caf80e8e7b19969133e9",
+ "url": "https://api.github.com/repos/dauxio/daux.io/zipball/0f03a24aef0e9f2544b08981e20a1c79dd8fbda0",
+ "reference": "0f03a24aef0e9f2544b08981e20a1c79dd8fbda0",
"shasum": ""
},
"require": {
- "guzzlehttp/guzzle": "~6.0",
- "league/commonmark": "^0.18",
+ "ext-json": "*",
+ "guzzlehttp/guzzle": "~6.0 || ~7.0",
+ "league/commonmark": "^1.0.0",
"league/plates": "~3.1",
- "myclabs/deep-copy": "^1.5",
- "php": ">=7.1.3",
+ "php": ">=7.2",
"scrivo/highlight.php": "^9.15",
- "symfony/console": "^4.0",
- "symfony/http-foundation": "^4.0",
+ "symfony/console": "^4.4|^5.0",
+ "symfony/http-foundation": "^4.4|^5.0",
+ "symfony/mime": "^4.4|^5.0",
"symfony/polyfill-intl-icu": "^1.10",
- "symfony/process": "^4.0",
- "webuni/commonmark-table-extension": "0.9.*",
+ "symfony/process": "^4.4|^5.0",
"webuni/front-matter": "^1.0.0"
},
"replace": {
"justinwalsh/daux.io": "*"
},
"require-dev": {
- "mikey179/vfsstream": "^1.6",
- "phpunit/phpunit": "~7.4"
+ "mikey179/vfsstream": "^1.6"
},
"suggest": {
"ext-intl": "Allows to translate the modified at date"
@@ -76,41 +75,52 @@
"markdown",
"md"
],
- "time": "2019-09-23T20:10:07+00:00"
+ "support": {
+ "issues": "https://github.com/dauxio/daux.io/issues",
+ "source": "https://github.com/dauxio/daux.io/tree/0.15.1"
+ },
+ "time": "2020-12-30T17:58:01+00:00"
},
{
"name": "guzzlehttp/guzzle",
- "version": "6.5.5",
+ "version": "7.3.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
- "reference": "9d4290de1cfd701f38099ef7e183b64b4b7b0c5e"
+ "reference": "7008573787b430c1c1f650e3722d9bba59967628"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/guzzle/zipball/9d4290de1cfd701f38099ef7e183b64b4b7b0c5e",
- "reference": "9d4290de1cfd701f38099ef7e183b64b4b7b0c5e",
+ "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7008573787b430c1c1f650e3722d9bba59967628",
+ "reference": "7008573787b430c1c1f650e3722d9bba59967628",
"shasum": ""
},
"require": {
"ext-json": "*",
- "guzzlehttp/promises": "^1.0",
- "guzzlehttp/psr7": "^1.6.1",
- "php": ">=5.5",
- "symfony/polyfill-intl-idn": "^1.17.0"
+ "guzzlehttp/promises": "^1.4",
+ "guzzlehttp/psr7": "^1.7 || ^2.0",
+ "php": "^7.2.5 || ^8.0",
+ "psr/http-client": "^1.0"
+ },
+ "provide": {
+ "psr/http-client-implementation": "1.0"
},
"require-dev": {
+ "bamarni/composer-bin-plugin": "^1.4.1",
"ext-curl": "*",
- "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.4 || ^7.0",
+ "php-http/client-integration-tests": "^3.0",
+ "phpunit/phpunit": "^8.5.5 || ^9.3.5",
"psr/log": "^1.1"
},
"suggest": {
+ "ext-curl": "Required for CURL handler support",
+ "ext-intl": "Required for Internationalized Domain Name (IDN) support",
"psr/log": "Required for using the Log middleware"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "6.5-dev"
+ "dev-master": "7.3-dev"
}
},
"autoload": {
@@ -130,6 +140,11 @@
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://sagikazarmark.hu"
}
],
"description": "Guzzle is a PHP HTTP client library",
@@ -140,23 +155,47 @@
"framework",
"http",
"http client",
+ "psr-18",
+ "psr-7",
"rest",
"web service"
],
- "time": "2020-06-16T21:01:06+00:00"
+ "support": {
+ "issues": "https://github.com/guzzle/guzzle/issues",
+ "source": "https://github.com/guzzle/guzzle/tree/7.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/alexeyshockov",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/gmponos",
+ "type": "github"
+ }
+ ],
+ "time": "2021-03-23T11:33:13+00:00"
},
{
"name": "guzzlehttp/promises",
- "version": "1.4.0",
+ "version": "1.4.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
- "reference": "60d379c243457e073cff02bc323a2a86cb355631"
+ "reference": "8e7d04f1f6450fef59366c399cfad4b9383aa30d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/promises/zipball/60d379c243457e073cff02bc323a2a86cb355631",
- "reference": "60d379c243457e073cff02bc323a2a86cb355631",
+ "url": "https://api.github.com/repos/guzzle/promises/zipball/8e7d04f1f6450fef59366c399cfad4b9383aa30d",
+ "reference": "8e7d04f1f6450fef59366c399cfad4b9383aa30d",
"shasum": ""
},
"require": {
@@ -194,33 +233,40 @@
"keywords": [
"promise"
],
- "time": "2020-09-30T07:37:28+00:00"
+ "support": {
+ "issues": "https://github.com/guzzle/promises/issues",
+ "source": "https://github.com/guzzle/promises/tree/1.4.1"
+ },
+ "time": "2021-03-07T09:25:29+00:00"
},
{
"name": "guzzlehttp/psr7",
- "version": "1.7.0",
+ "version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
- "reference": "53330f47520498c0ae1f61f7e2c90f55690c06a3"
+ "reference": "1dc8d9cba3897165e16d12bb13d813afb1eb3fe7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/psr7/zipball/53330f47520498c0ae1f61f7e2c90f55690c06a3",
- "reference": "53330f47520498c0ae1f61f7e2c90f55690c06a3",
+ "url": "https://api.github.com/repos/guzzle/psr7/zipball/1dc8d9cba3897165e16d12bb13d813afb1eb3fe7",
+ "reference": "1dc8d9cba3897165e16d12bb13d813afb1eb3fe7",
"shasum": ""
},
"require": {
- "php": ">=5.4.0",
- "psr/http-message": "~1.0",
- "ralouphie/getallheaders": "^2.0.5 || ^3.0.0"
+ "php": "^7.2.5 || ^8.0",
+ "psr/http-factory": "^1.0",
+ "psr/http-message": "^1.0",
+ "ralouphie/getallheaders": "^3.0"
},
"provide": {
+ "psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
},
"require-dev": {
- "ext-zlib": "*",
- "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.8 || ^9.3.10"
+ "bamarni/composer-bin-plugin": "^1.4.1",
+ "http-interop/http-factory-tests": "^0.9",
+ "phpunit/phpunit": "^8.5.8 || ^9.3.10"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
@@ -228,16 +274,13 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.7-dev"
+ "dev-master": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Psr7\\": "src/"
- },
- "files": [
- "src/functions_include.php"
- ]
+ }
},
"notification-url": "https://packagist.org/downloads/",
"license": [
@@ -252,6 +295,11 @@
{
"name": "Tobias Schultze",
"homepage": "https://github.com/Tobion"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://sagikazarmark.hu"
}
],
"description": "PSR-7 message implementation that also provides common utility methods",
@@ -265,54 +313,53 @@
"uri",
"url"
],
- "time": "2020-09-30T07:37:11+00:00"
+ "support": {
+ "issues": "https://github.com/guzzle/psr7/issues",
+ "source": "https://github.com/guzzle/psr7/tree/2.0.0"
+ },
+ "time": "2021-06-30T20:03:07+00:00"
},
{
"name": "league/commonmark",
- "version": "0.18.5",
+ "version": "1.6.5",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
- "reference": "f94e18d68260f43a7d846279cad88405854b1306"
+ "reference": "44ffd8d3c4a9133e4bd0548622b09c55af39db5f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/f94e18d68260f43a7d846279cad88405854b1306",
- "reference": "f94e18d68260f43a7d846279cad88405854b1306",
+ "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/44ffd8d3c4a9133e4bd0548622b09c55af39db5f",
+ "reference": "44ffd8d3c4a9133e4bd0548622b09c55af39db5f",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
- "php": ">=5.6.5"
+ "php": "^7.1 || ^8.0"
},
- "replace": {
- "colinodell/commonmark-php": "*"
+ "conflict": {
+ "scrutinizer/ocular": "1.7.*"
},
"require-dev": {
"cebe/markdown": "~1.0",
- "commonmark/commonmark.js": "0.28",
+ "commonmark/commonmark.js": "0.29.2",
"erusev/parsedown": "~1.0",
+ "ext-json": "*",
+ "github/gfm": "0.29.0",
"michelf/php-markdown": "~1.4",
- "mikehaertl/php-shellcommand": "^1.2",
- "phpunit/phpunit": "^5.7.27|^6.5.14",
- "scrutinizer/ocular": "^1.1",
- "symfony/finder": "^3.0|^4.0"
- },
- "suggest": {
- "league/commonmark-extras": "Library of useful extensions including smart punctuation"
+ "mikehaertl/php-shellcommand": "^1.4",
+ "phpstan/phpstan": "^0.12.90",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.2",
+ "scrutinizer/ocular": "^1.5",
+ "symfony/finder": "^4.2"
},
"bin": [
"bin/commonmark"
],
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "0.19-dev"
- }
- },
"autoload": {
"psr-4": {
- "League\\CommonMark\\": "src/"
+ "League\\CommonMark\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -327,36 +374,73 @@
"role": "Lead Developer"
}
],
- "description": "PHP Markdown parser based on the CommonMark spec",
- "homepage": "https://github.com/thephpleague/commonmark",
+ "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and Github-Flavored Markdown (GFM)",
+ "homepage": "https://commonmark.thephpleague.com",
"keywords": [
"commonmark",
+ "flavored",
+ "gfm",
+ "github",
+ "github-flavored",
"markdown",
+ "md",
"parser"
],
- "time": "2019-03-28T13:52:31+00:00"
+ "support": {
+ "docs": "https://commonmark.thephpleague.com/",
+ "issues": "https://github.com/thephpleague/commonmark/issues",
+ "rss": "https://github.com/thephpleague/commonmark/releases.atom",
+ "source": "https://github.com/thephpleague/commonmark"
+ },
+ "funding": [
+ {
+ "url": "https://enjoy.gitstore.app/repositories/thephpleague/commonmark",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.colinodell.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.paypal.me/colinpodell/10.00",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/colinodell",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/colinodell",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/league/commonmark",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2021-06-26T11:57:13+00:00"
},
{
"name": "league/plates",
- "version": "3.3.0",
+ "version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/plates.git",
- "reference": "b1684b6f127714497a0ef927ce42c0b44b45a8af"
+ "reference": "6d3ee31199b536a4e003b34a356ca20f6f75496a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/plates/zipball/b1684b6f127714497a0ef927ce42c0b44b45a8af",
- "reference": "b1684b6f127714497a0ef927ce42c0b44b45a8af",
+ "url": "https://api.github.com/repos/thephpleague/plates/zipball/6d3ee31199b536a4e003b34a356ca20f6f75496a",
+ "reference": "6d3ee31199b536a4e003b34a356ca20f6f75496a",
"shasum": ""
},
"require": {
- "php": "^5.3 | ^7.0"
+ "php": "^7.0|^8.0"
},
"require-dev": {
- "mikey179/vfsstream": "^1.4",
- "phpunit/phpunit": "~4.0",
- "squizlabs/php_codesniffer": "~1.5"
+ "mikey179/vfsstream": "^1.6",
+ "phpunit/phpunit": "^9.5",
+ "squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
@@ -378,10 +462,15 @@
"name": "Jonathan Reinink",
"email": "jonathan@reinink.ca",
"role": "Developer"
+ },
+ {
+ "name": "RJ Garcia",
+ "email": "ragboyjr@icloud.com",
+ "role": "Developer"
}
],
"description": "Plates, the native PHP template system that's fast, easy to use and easy to extend.",
- "homepage": "http://platesphp.com",
+ "homepage": "https://platesphp.com",
"keywords": [
"league",
"package",
@@ -389,85 +478,30 @@
"templating",
"views"
],
- "time": "2016-12-28T00:14:17+00:00"
- },
- {
- "name": "myclabs/deep-copy",
- "version": "1.10.1",
- "source": {
- "type": "git",
- "url": "https://github.com/myclabs/DeepCopy.git",
- "reference": "969b211f9a51aa1f6c01d1d2aef56d3bd91598e5"
+ "support": {
+ "issues": "https://github.com/thephpleague/plates/issues",
+ "source": "https://github.com/thephpleague/plates/tree/v3.4.0"
},
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/969b211f9a51aa1f6c01d1d2aef56d3bd91598e5",
- "reference": "969b211f9a51aa1f6c01d1d2aef56d3bd91598e5",
- "shasum": ""
- },
- "require": {
- "php": "^7.1 || ^8.0"
- },
- "replace": {
- "myclabs/deep-copy": "self.version"
- },
- "require-dev": {
- "doctrine/collections": "^1.0",
- "doctrine/common": "^2.6",
- "phpunit/phpunit": "^7.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",
- "keywords": [
- "clone",
- "copy",
- "duplicate",
- "object",
- "object graph"
- ],
- "funding": [
- {
- "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
- "type": "tidelift"
- }
- ],
- "time": "2020-06-29T13:22:24+00:00"
+ "time": "2020-12-25T05:00:37+00:00"
},
{
"name": "psr/container",
- "version": "1.0.0",
+ "version": "1.1.1",
"source": {
"type": "git",
"url": "https://github.com/php-fig/container.git",
- "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f"
+ "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-fig/container/zipball/b7ce3b176482dbbc1245ebf52b181af44c2cf55f",
- "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f",
+ "url": "https://api.github.com/repos/php-fig/container/zipball/8622567409010282b7aeebe4bb841fe98b58dcaf",
+ "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf",
"shasum": ""
},
"require": {
- "php": ">=5.3.0"
+ "php": ">=7.2.0"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.0.x-dev"
- }
- },
"autoload": {
"psr-4": {
"Psr\\Container\\": "src/"
@@ -480,7 +514,7 @@
"authors": [
{
"name": "PHP-FIG",
- "homepage": "http://www.php-fig.org/"
+ "homepage": "https://www.php-fig.org/"
}
],
"description": "Common Container Interface (PHP FIG PSR-11)",
@@ -492,7 +526,118 @@
"container-interop",
"psr"
],
- "time": "2017-02-14T16:28:37+00:00"
+ "support": {
+ "issues": "https://github.com/php-fig/container/issues",
+ "source": "https://github.com/php-fig/container/tree/1.1.1"
+ },
+ "time": "2021-03-05T17:36:06+00:00"
+ },
+ {
+ "name": "psr/http-client",
+ "version": "1.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-client.git",
+ "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-client/zipball/2dfb5f6c5eff0e91e20e913f8c5452ed95b86621",
+ "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0 || ^8.0",
+ "psr/http-message": "^1.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Client\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP clients",
+ "homepage": "https://github.com/php-fig/http-client",
+ "keywords": [
+ "http",
+ "http-client",
+ "psr",
+ "psr-18"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-client/tree/master"
+ },
+ "time": "2020-06-29T06:28:15+00:00"
+ },
+ {
+ "name": "psr/http-factory",
+ "version": "1.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-factory.git",
+ "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-factory/zipball/12ac7fcd07e5b077433f5f2bee95b3a771bf61be",
+ "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.0.0",
+ "psr/http-message": "^1.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interfaces for PSR-7 HTTP message factories",
+ "keywords": [
+ "factory",
+ "http",
+ "message",
+ "psr",
+ "psr-17",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-factory/tree/master"
+ },
+ "time": "2019-04-30T12:38:16+00:00"
},
{
"name": "psr/http-message",
@@ -542,6 +687,9 @@
"request",
"response"
],
+ "support": {
+ "source": "https://github.com/php-fig/http-message/tree/master"
+ },
"time": "2016-08-06T14:39:51+00:00"
},
{
@@ -582,20 +730,24 @@
}
],
"description": "A polyfill for getallheaders.",
+ "support": {
+ "issues": "https://github.com/ralouphie/getallheaders/issues",
+ "source": "https://github.com/ralouphie/getallheaders/tree/develop"
+ },
"time": "2019-03-08T08:55:37+00:00"
},
{
"name": "scrivo/highlight.php",
- "version": "v9.18.1.3",
+ "version": "v9.18.1.6",
"source": {
"type": "git",
"url": "https://github.com/scrivo/highlight.php.git",
- "reference": "6a1699707b099081f20a488ac1f92d682181018c"
+ "reference": "44a3d4136edb5ad8551590bf90f437db80b2d466"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/scrivo/highlight.php/zipball/6a1699707b099081f20a488ac1f92d682181018c",
- "reference": "6a1699707b099081f20a488ac1f92d682181018c",
+ "url": "https://api.github.com/repos/scrivo/highlight.php/zipball/44a3d4136edb5ad8551590bf90f437db80b2d466",
+ "reference": "44a3d4136edb5ad8551590bf90f437db80b2d466",
"shasum": ""
},
"require": {
@@ -609,9 +761,6 @@
"symfony/finder": "^2.8|^3.4",
"symfony/var-dumper": "^2.8|^3.4"
},
- "suggest": {
- "ext-dom": "Needed to make use of the features in the utilities namespace"
- },
"type": "library",
"autoload": {
"psr-0": {
@@ -651,52 +800,59 @@
"highlight.php",
"syntax"
],
+ "support": {
+ "issues": "https://github.com/scrivo/highlight.php/issues",
+ "source": "https://github.com/scrivo/highlight.php"
+ },
"funding": [
{
"url": "https://github.com/allejo",
"type": "github"
}
],
- "time": "2020-10-16T07:43:22+00:00"
+ "time": "2020-12-22T19:20:29+00:00"
},
{
"name": "symfony/console",
- "version": "v4.4.15",
+ "version": "v5.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "90933b39c7b312fc3ceaa1ddeac7eb48cb953124"
+ "reference": "649730483885ff2ca99ca0560ef0e5f6b03f2ac1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/90933b39c7b312fc3ceaa1ddeac7eb48cb953124",
- "reference": "90933b39c7b312fc3ceaa1ddeac7eb48cb953124",
+ "url": "https://api.github.com/repos/symfony/console/zipball/649730483885ff2ca99ca0560ef0e5f6b03f2ac1",
+ "reference": "649730483885ff2ca99ca0560ef0e5f6b03f2ac1",
"shasum": ""
},
"require": {
- "php": ">=7.1.3",
+ "php": ">=7.2.5",
+ "symfony/deprecation-contracts": "^2.1",
"symfony/polyfill-mbstring": "~1.0",
"symfony/polyfill-php73": "^1.8",
"symfony/polyfill-php80": "^1.15",
- "symfony/service-contracts": "^1.1|^2"
+ "symfony/service-contracts": "^1.1|^2",
+ "symfony/string": "^5.1"
},
"conflict": {
- "symfony/dependency-injection": "<3.4",
- "symfony/event-dispatcher": "<4.3|>=5",
+ "symfony/dependency-injection": "<4.4",
+ "symfony/dotenv": "<5.1",
+ "symfony/event-dispatcher": "<4.4",
"symfony/lock": "<4.4",
- "symfony/process": "<3.3"
+ "symfony/process": "<4.4"
},
"provide": {
"psr/log-implementation": "1.0"
},
"require-dev": {
"psr/log": "~1.0",
- "symfony/config": "^3.4|^4.0|^5.0",
- "symfony/dependency-injection": "^3.4|^4.0|^5.0",
- "symfony/event-dispatcher": "^4.3",
+ "symfony/config": "^4.4|^5.0",
+ "symfony/dependency-injection": "^4.4|^5.0",
+ "symfony/event-dispatcher": "^4.4|^5.0",
"symfony/lock": "^4.4|^5.0",
- "symfony/process": "^3.4|^4.0|^5.0",
- "symfony/var-dumper": "^4.3|^5.0"
+ "symfony/process": "^4.4|^5.0",
+ "symfony/var-dumper": "^4.4|^5.0"
},
"suggest": {
"psr/log": "For using the console logger",
@@ -705,11 +861,6 @@
"symfony/process": ""
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "4.4-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Console\\": ""
@@ -732,8 +883,17 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Console Component",
+ "description": "Eases the creation of beautiful and testable command line interfaces",
"homepage": "https://symfony.com",
+ "keywords": [
+ "cli",
+ "command line",
+ "console",
+ "terminal"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/console/tree/v5.3.2"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -748,37 +908,105 @@
"type": "tidelift"
}
],
- "time": "2020-09-15T07:58:55+00:00"
+ "time": "2021-06-12T09:42:48+00:00"
},
{
- "name": "symfony/http-foundation",
- "version": "v4.4.15",
+ "name": "symfony/deprecation-contracts",
+ "version": "v2.4.0",
"source": {
"type": "git",
- "url": "https://github.com/symfony/http-foundation.git",
- "reference": "10683b407c3b6087c64619ebc97a87e36ea62c92"
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "5f38c8804a9e97d23e0c8d63341088cd8a22d627"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-foundation/zipball/10683b407c3b6087c64619ebc97a87e36ea62c92",
- "reference": "10683b407c3b6087c64619ebc97a87e36ea62c92",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/5f38c8804a9e97d23e0c8d63341088cd8a22d627",
+ "reference": "5f38c8804a9e97d23e0c8d63341088cd8a22d627",
"shasum": ""
},
"require": {
- "php": ">=7.1.3",
- "symfony/mime": "^4.3|^5.0",
- "symfony/polyfill-mbstring": "~1.1"
- },
- "require-dev": {
- "predis/predis": "~1.0",
- "symfony/expression-language": "^3.4|^4.0|^5.0"
+ "php": ">=7.1"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.4-dev"
+ "dev-main": "2.4-dev"
+ },
+ "thanks": {
+ "name": "symfony/contracts",
+ "url": "https://github.com/symfony/contracts"
}
},
+ "autoload": {
+ "files": [
+ "function.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "A generic function and convention to trigger deprecation notices",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v2.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2021-03-23T23:28:01+00:00"
+ },
+ {
+ "name": "symfony/http-foundation",
+ "version": "v5.3.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/http-foundation.git",
+ "reference": "0e45ab1574caa0460d9190871a8ce47539e40ccf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/http-foundation/zipball/0e45ab1574caa0460d9190871a8ce47539e40ccf",
+ "reference": "0e45ab1574caa0460d9190871a8ce47539e40ccf",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.5",
+ "symfony/deprecation-contracts": "^2.1",
+ "symfony/polyfill-mbstring": "~1.1",
+ "symfony/polyfill-php80": "^1.15"
+ },
+ "require-dev": {
+ "predis/predis": "~1.0",
+ "symfony/cache": "^4.4|^5.0",
+ "symfony/expression-language": "^4.4|^5.0",
+ "symfony/mime": "^4.4|^5.0"
+ },
+ "suggest": {
+ "symfony/mime": "To use the file extension guesser"
+ },
+ "type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpFoundation\\": ""
@@ -801,8 +1029,11 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony HttpFoundation Component",
+ "description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/http-foundation/tree/v5.3.3"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -817,131 +1048,44 @@
"type": "tidelift"
}
],
- "time": "2020-09-27T14:14:06+00:00"
- },
- {
- "name": "symfony/intl",
- "version": "v5.1.7",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/intl.git",
- "reference": "9381fd69ce6407041185aa6f1bafbf7d65f0e66a"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/intl/zipball/9381fd69ce6407041185aa6f1bafbf7d65f0e66a",
- "reference": "9381fd69ce6407041185aa6f1bafbf7d65f0e66a",
- "shasum": ""
- },
- "require": {
- "php": ">=7.2.5",
- "symfony/polyfill-intl-icu": "~1.0",
- "symfony/polyfill-php80": "^1.15"
- },
- "require-dev": {
- "symfony/filesystem": "^4.4|^5.0"
- },
- "suggest": {
- "ext-intl": "to use the component with locales other than \"en\""
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Symfony\\Component\\Intl\\": ""
- },
- "classmap": [
- "Resources/stubs"
- ],
- "exclude-from-classmap": [
- "/Tests/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Bernhard Schussek",
- "email": "bschussek@gmail.com"
- },
- {
- "name": "Eriksen Costa",
- "email": "eriksen.costa@infranology.com.br"
- },
- {
- "name": "Igor Wiedler",
- "email": "igor@wiedler.ch"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "A PHP replacement layer for the C intl extension that includes additional data from the ICU library.",
- "homepage": "https://symfony.com",
- "keywords": [
- "i18n",
- "icu",
- "internationalization",
- "intl",
- "l10n",
- "localization"
- ],
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2020-09-27T03:44:28+00:00"
+ "time": "2021-06-27T09:19:40+00:00"
},
{
"name": "symfony/mime",
- "version": "v5.1.7",
+ "version": "v5.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
- "reference": "4404d6545125863561721514ad9388db2661eec5"
+ "reference": "47dd7912152b82d0d4c8d9040dbc93d6232d472a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/mime/zipball/4404d6545125863561721514ad9388db2661eec5",
- "reference": "4404d6545125863561721514ad9388db2661eec5",
+ "url": "https://api.github.com/repos/symfony/mime/zipball/47dd7912152b82d0d4c8d9040dbc93d6232d472a",
+ "reference": "47dd7912152b82d0d4c8d9040dbc93d6232d472a",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
+ "symfony/deprecation-contracts": "^2.1",
"symfony/polyfill-intl-idn": "^1.10",
"symfony/polyfill-mbstring": "^1.0",
"symfony/polyfill-php80": "^1.15"
},
"conflict": {
+ "egulias/email-validator": "~3.0.0",
+ "phpdocumentor/reflection-docblock": "<3.2.2",
+ "phpdocumentor/type-resolver": "<1.4.0",
"symfony/mailer": "<4.4"
},
"require-dev": {
- "egulias/email-validator": "^2.1.10",
- "symfony/dependency-injection": "^4.4|^5.0"
+ "egulias/email-validator": "^2.1.10|^3.1",
+ "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
+ "symfony/dependency-injection": "^4.4|^5.0",
+ "symfony/property-access": "^4.4|^5.1",
+ "symfony/property-info": "^4.4|^5.1",
+ "symfony/serializer": "^5.2"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Mime\\": ""
@@ -964,12 +1108,15 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "A library to manipulate MIME messages",
+ "description": "Allows manipulating MIME messages",
"homepage": "https://symfony.com",
"keywords": [
"mime",
"mime-type"
],
+ "support": {
+ "source": "https://github.com/symfony/mime/tree/v5.3.2"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -984,20 +1131,20 @@
"type": "tidelift"
}
],
- "time": "2020-09-02T16:23:27+00:00"
+ "time": "2021-06-09T10:58:01+00:00"
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
- "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41"
+ "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f4ba089a5b6366e453971d3aad5fe8e897b37f41",
- "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/46cd95797e9df938fdd2b03693b5fca5e64b01ce",
+ "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce",
"shasum": ""
},
"require": {
@@ -1009,7 +1156,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1046,6 +1193,9 @@
"polyfill",
"portable"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1060,25 +1210,24 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-02-19T12:13:01+00:00"
},
{
- "name": "symfony/polyfill-intl-icu",
- "version": "v1.20.0",
+ "name": "symfony/polyfill-intl-grapheme",
+ "version": "v1.23.0",
"source": {
"type": "git",
- "url": "https://github.com/symfony/polyfill-intl-icu.git",
- "reference": "c44d5bf6a75eed79555c6bf37505c6d39559353e"
+ "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
+ "reference": "24b72c6baa32c746a4d0840147c9715e42bb68ab"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/c44d5bf6a75eed79555c6bf37505c6d39559353e",
- "reference": "c44d5bf6a75eed79555c6bf37505c6d39559353e",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/24b72c6baa32c746a4d0840147c9715e42bb68ab",
+ "reference": "24b72c6baa32c746a4d0840147c9715e42bb68ab",
"shasum": ""
},
"require": {
- "php": ">=7.1",
- "symfony/intl": "~2.3|~3.0|~4.0|~5.0"
+ "php": ">=7.1"
},
"suggest": {
"ext-intl": "For best performance"
@@ -1086,7 +1235,88 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
+ },
+ "thanks": {
+ "name": "symfony/polyfill",
+ "url": "https://github.com/symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+ },
+ "files": [
+ "bootstrap.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's grapheme_* functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "grapheme",
+ "intl",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.23.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2021-05-27T09:17:38+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-icu",
+ "version": "v1.23.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-icu.git",
+ "reference": "4a80a521d6176870b6445cfb469c130f9cae1dda"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/4a80a521d6176870b6445cfb469c130f9cae1dda",
+ "reference": "4a80a521d6176870b6445cfb469c130f9cae1dda",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1"
+ },
+ "suggest": {
+ "ext-intl": "For best performance and support of other locales than \"en\""
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1096,6 +1326,15 @@
"autoload": {
"files": [
"bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Icu\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ],
+ "exclude-from-classmap": [
+ "/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
@@ -1122,6 +1361,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1136,20 +1378,20 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-05-24T10:04:56+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
- "reference": "3b75acd829741c768bc8b1f84eb33265e7cc5117"
+ "reference": "65bd267525e82759e7d8c4e8ceea44f398838e65"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/3b75acd829741c768bc8b1f84eb33265e7cc5117",
- "reference": "3b75acd829741c768bc8b1f84eb33265e7cc5117",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/65bd267525e82759e7d8c4e8ceea44f398838e65",
+ "reference": "65bd267525e82759e7d8c4e8ceea44f398838e65",
"shasum": ""
},
"require": {
@@ -1163,7 +1405,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1206,6 +1448,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1220,20 +1465,20 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-05-27T09:27:20+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
- "reference": "727d1096295d807c309fb01a851577302394c897"
+ "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/727d1096295d807c309fb01a851577302394c897",
- "reference": "727d1096295d807c309fb01a851577302394c897",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8590a5f561694770bdcd3f9b5c69dde6945028e8",
+ "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8",
"shasum": ""
},
"require": {
@@ -1245,7 +1490,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1287,6 +1532,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1301,20 +1549,20 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-02-19T12:13:01+00:00"
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "39d483bdf39be819deabf04ec872eb0b2410b531"
+ "reference": "2df51500adbaebdc4c38dea4c89a2e131c45c8a1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/39d483bdf39be819deabf04ec872eb0b2410b531",
- "reference": "39d483bdf39be819deabf04ec872eb0b2410b531",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/2df51500adbaebdc4c38dea4c89a2e131c45c8a1",
+ "reference": "2df51500adbaebdc4c38dea4c89a2e131c45c8a1",
"shasum": ""
},
"require": {
@@ -1326,7 +1574,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1364,6 +1612,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1378,20 +1629,20 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-05-27T09:27:20+00:00"
},
{
"name": "symfony/polyfill-php72",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php72.git",
- "reference": "cede45fcdfabdd6043b3592e83678e42ec69e930"
+ "reference": "9a142215a36a3888e30d0a9eeea9766764e96976"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/cede45fcdfabdd6043b3592e83678e42ec69e930",
- "reference": "cede45fcdfabdd6043b3592e83678e42ec69e930",
+ "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/9a142215a36a3888e30d0a9eeea9766764e96976",
+ "reference": "9a142215a36a3888e30d0a9eeea9766764e96976",
"shasum": ""
},
"require": {
@@ -1400,7 +1651,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1437,6 +1688,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php72/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1451,20 +1705,20 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-05-27T09:17:38+00:00"
},
{
"name": "symfony/polyfill-php73",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php73.git",
- "reference": "8ff431c517be11c78c48a39a66d37431e26a6bed"
+ "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/8ff431c517be11c78c48a39a66d37431e26a6bed",
- "reference": "8ff431c517be11c78c48a39a66d37431e26a6bed",
+ "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/fba8933c384d6476ab14fb7b8526e5287ca7e010",
+ "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010",
"shasum": ""
},
"require": {
@@ -1473,7 +1727,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1513,6 +1767,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php73/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1527,20 +1784,20 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-02-19T12:13:01+00:00"
},
{
"name": "symfony/polyfill-php80",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
- "reference": "e70aa8b064c5b72d3df2abd5ab1e90464ad009de"
+ "reference": "eca0bf41ed421bed1b57c4958bab16aa86b757d0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/e70aa8b064c5b72d3df2abd5ab1e90464ad009de",
- "reference": "e70aa8b064c5b72d3df2abd5ab1e90464ad009de",
+ "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/eca0bf41ed421bed1b57c4958bab16aa86b757d0",
+ "reference": "eca0bf41ed421bed1b57c4958bab16aa86b757d0",
"shasum": ""
},
"require": {
@@ -1549,7 +1806,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1593,6 +1850,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php80/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1607,31 +1867,27 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-02-19T12:13:01+00:00"
},
{
"name": "symfony/process",
- "version": "v4.4.15",
+ "version": "v5.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "9b887acc522935f77555ae8813495958c7771ba7"
+ "reference": "714b47f9196de61a196d86c4bad5f09201b307df"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/9b887acc522935f77555ae8813495958c7771ba7",
- "reference": "9b887acc522935f77555ae8813495958c7771ba7",
+ "url": "https://api.github.com/repos/symfony/process/zipball/714b47f9196de61a196d86c4bad5f09201b307df",
+ "reference": "714b47f9196de61a196d86c4bad5f09201b307df",
"shasum": ""
},
"require": {
- "php": ">=7.1.3"
+ "php": ">=7.2.5",
+ "symfony/polyfill-php80": "^1.15"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "4.4-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Process\\": ""
@@ -1654,8 +1910,11 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Process Component",
+ "description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/process/tree/v5.3.2"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1670,25 +1929,25 @@
"type": "tidelift"
}
],
- "time": "2020-09-02T16:08:58+00:00"
+ "time": "2021-06-12T10:15:01+00:00"
},
{
"name": "symfony/service-contracts",
- "version": "v2.2.0",
+ "version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
- "reference": "d15da7ba4957ffb8f1747218be9e1a121fd298a1"
+ "reference": "f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d15da7ba4957ffb8f1747218be9e1a121fd298a1",
- "reference": "d15da7ba4957ffb8f1747218be9e1a121fd298a1",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb",
+ "reference": "f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
- "psr/container": "^1.0"
+ "psr/container": "^1.1"
},
"suggest": {
"symfony/service-implementation": ""
@@ -1696,7 +1955,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.2-dev"
+ "dev-main": "2.4-dev"
},
"thanks": {
"name": "symfony/contracts",
@@ -1732,6 +1991,9 @@
"interoperability",
"standards"
],
+ "support": {
+ "source": "https://github.com/symfony/service-contracts/tree/v2.4.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1746,41 +2008,123 @@
"type": "tidelift"
}
],
- "time": "2020-09-07T11:33:47+00:00"
+ "time": "2021-04-01T10:43:52+00:00"
},
{
- "name": "symfony/yaml",
- "version": "v4.4.15",
+ "name": "symfony/string",
+ "version": "v5.3.3",
"source": {
"type": "git",
- "url": "https://github.com/symfony/yaml.git",
- "reference": "c7885964b1eceb70b0981556d0a9b01d2d97c8d1"
+ "url": "https://github.com/symfony/string.git",
+ "reference": "bd53358e3eccec6a670b5f33ab680d8dbe1d4ae1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/yaml/zipball/c7885964b1eceb70b0981556d0a9b01d2d97c8d1",
- "reference": "c7885964b1eceb70b0981556d0a9b01d2d97c8d1",
+ "url": "https://api.github.com/repos/symfony/string/zipball/bd53358e3eccec6a670b5f33ab680d8dbe1d4ae1",
+ "reference": "bd53358e3eccec6a670b5f33ab680d8dbe1d4ae1",
"shasum": ""
},
"require": {
- "php": ">=7.1.3",
+ "php": ">=7.2.5",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-intl-grapheme": "~1.0",
+ "symfony/polyfill-intl-normalizer": "~1.0",
+ "symfony/polyfill-mbstring": "~1.0",
+ "symfony/polyfill-php80": "~1.15"
+ },
+ "require-dev": {
+ "symfony/error-handler": "^4.4|^5.0",
+ "symfony/http-client": "^4.4|^5.0",
+ "symfony/translation-contracts": "^1.1|^2",
+ "symfony/var-exporter": "^4.4|^5.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\String\\": ""
+ },
+ "files": [
+ "Resources/functions.php"
+ ],
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "grapheme",
+ "i18n",
+ "string",
+ "unicode",
+ "utf-8",
+ "utf8"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/string/tree/v5.3.3"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2021-06-27T11:44:38+00:00"
+ },
+ {
+ "name": "symfony/yaml",
+ "version": "v5.3.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/yaml.git",
+ "reference": "485c83a2fb5893e2ff21bf4bfc7fdf48b4967229"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/485c83a2fb5893e2ff21bf4bfc7fdf48b4967229",
+ "reference": "485c83a2fb5893e2ff21bf4bfc7fdf48b4967229",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.5",
+ "symfony/deprecation-contracts": "^2.1",
"symfony/polyfill-ctype": "~1.8"
},
"conflict": {
- "symfony/console": "<3.4"
+ "symfony/console": "<4.4"
},
"require-dev": {
- "symfony/console": "^3.4|^4.0|^5.0"
+ "symfony/console": "^4.4|^5.0"
},
"suggest": {
"symfony/console": "For validating YAML files using the lint command"
},
+ "bin": [
+ "Resources/bin/yaml-lint"
+ ],
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "4.4-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
@@ -1803,8 +2147,11 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Yaml Component",
+ "description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/yaml/tree/v5.3.3"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1819,94 +2166,33 @@
"type": "tidelift"
}
],
- "time": "2020-09-27T03:36:23+00:00"
- },
- {
- "name": "webuni/commonmark-table-extension",
- "version": "0.9.0",
- "source": {
- "type": "git",
- "url": "https://github.com/webuni/commonmark-table-extension.git",
- "reference": "94bc98d802d0b706e748716854e5fa0bd3644df3"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/webuni/commonmark-table-extension/zipball/94bc98d802d0b706e748716854e5fa0bd3644df3",
- "reference": "94bc98d802d0b706e748716854e5fa0bd3644df3",
- "shasum": ""
- },
- "require": {
- "league/commonmark": "^0.16|^0.17|^0.18",
- "php": "^5.6|^7.0"
- },
- "require-dev": {
- "friendsofphp/php-cs-fixer": "^2.9",
- "phpunit/phpunit": "^5.4|^6.0",
- "symfony/var-dumper": "^3.0|^4.0",
- "vimeo/psalm": "~0.3"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "0.9-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Webuni\\CommonMark\\TableExtension\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Martin Hasoň",
- "email": "martin.hason@gmail.com"
- },
- {
- "name": "Webuni s.r.o.",
- "homepage": "https://www.webuni.cz"
- }
- ],
- "description": "The table extension for CommonMark PHP implementation",
- "homepage": "https://github.com/webuni/commonmark-table-extension",
- "keywords": [
- "commonmark",
- "markdown",
- "table"
- ],
- "abandoned": "league/commonmark",
- "time": "2018-11-28T11:29:11+00:00"
+ "time": "2021-06-24T08:13:00+00:00"
},
{
"name": "webuni/front-matter",
- "version": "1.1.0",
+ "version": "1.3.0",
"source": {
"type": "git",
"url": "https://github.com/webuni/front-matter.git",
- "reference": "c7d1c51f9864ff015365ce515374e63bcd3b558e"
+ "reference": "334e3532546d62d28164580208edef1c5c853024"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/webuni/front-matter/zipball/c7d1c51f9864ff015365ce515374e63bcd3b558e",
- "reference": "c7d1c51f9864ff015365ce515374e63bcd3b558e",
+ "url": "https://api.github.com/repos/webuni/front-matter/zipball/334e3532546d62d28164580208edef1c5c853024",
+ "reference": "334e3532546d62d28164580208edef1c5c853024",
"shasum": ""
},
"require": {
- "php": "^5.6|^7.0",
- "symfony/yaml": "^2.3|^3.0|^4.0"
+ "php": "^7.2 || ^8.0",
+ "symfony/yaml": "^3.4.31 || ^4.3.4 || ^5.0"
},
"require-dev": {
- "friendsofphp/php-cs-fixer": "^2.9",
+ "ext-json": "*",
+ "league/commonmark": "^1.4",
"mthaml/mthaml": "^1.3",
- "nette/neon": "^2.2",
- "phpunit/phpunit": "^5.7|^6.0|^7.0",
- "symfony/var-dumper": "^3.0|^4.0",
- "twig/twig": "^1.27|^2.0",
- "vimeo/psalm": "^1.0",
- "yosymfony/toml": "~0.3|^1.0"
+ "nette/neon": "^2.2 || ^3.0",
+ "twig/twig": "^3.0",
+ "yosymfony/toml": "^1.0"
},
"suggest": {
"nette/neon": "If you want to use NEON as front matter",
@@ -1915,7 +2201,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.2-dev"
+ "dev-master": "1.3-dev"
}
},
"autoload": {
@@ -1930,7 +2216,8 @@
"authors": [
{
"name": "Martin Hasoň",
- "email": "martin.hason@gmail.com"
+ "email": "martin.hason@gmail.com",
+ "homepage": "https://www.martinhason.cz"
},
{
"name": "Webuni s.r.o.",
@@ -1940,13 +2227,18 @@
"description": "Front matter parser and dumper for PHP",
"homepage": "https://github.com/webuni/front-matter",
"keywords": [
+ "commonmark",
"front-matter",
"json",
"neon",
"toml",
"yaml"
],
- "time": "2018-03-20T13:36:33+00:00"
+ "support": {
+ "issues": "https://github.com/webuni/front-matter/issues",
+ "source": "https://github.com/webuni/front-matter/tree/1.3.0"
+ },
+ "time": "2021-06-14T20:44:30+00:00"
}
],
"aliases": [],
@@ -1956,5 +2248,5 @@
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
- "plugin-api-version": "1.1.0"
+ "plugin-api-version": "2.0.0"
}
diff --git a/vendor-bin/phpunit/composer.json b/vendor-bin/phpunit/composer.json
index bfb4453b..5c333480 100644
--- a/vendor-bin/phpunit/composer.json
+++ b/vendor-bin/phpunit/composer.json
@@ -1,10 +1,10 @@
{
"require-dev": {
- "phpunit/phpunit": "^8.0",
- "dms/phpunit-arraysubset-asserts": "^0.1",
- "phake/phake": "^3.0",
+ "phpunit/phpunit": "^8.0 || ^9.0",
+ "dms/phpunit-arraysubset-asserts": "^0.1 || ^0.2",
"clue/arguments": "^2.0",
"mikey179/vfsstream": "^1.6",
- "webmozart/glob": "^4.1"
+ "webmozart/glob": "^4.1",
+ "eloquent/phony-phpunit": "^6.0 || ^7.0"
}
}
diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock
index 938fc8f3..dbfb1cbb 100644
--- a/vendor-bin/phpunit/composer.lock
+++ b/vendor-bin/phpunit/composer.lock
@@ -4,26 +4,29 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "af132f4314b7e577912ffde57af60aab",
+ "content-hash": "ee93856e05f6ce1cc763b474c94dad6c",
"packages": [],
"packages-dev": [
{
"name": "clue/arguments",
- "version": "v2.0.0",
+ "version": "v2.1.0",
"source": {
"type": "git",
- "url": "https://github.com/clue/php-arguments.git",
- "reference": "eb8356918bc51ac7e595e4ad92a2bc1c1d2754c2"
+ "url": "https://github.com/clue/arguments.git",
+ "reference": "87f2c4bc2ff602173bc52f5935a9c3b70d8c996d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/clue/php-arguments/zipball/eb8356918bc51ac7e595e4ad92a2bc1c1d2754c2",
- "reference": "eb8356918bc51ac7e595e4ad92a2bc1c1d2754c2",
+ "url": "https://api.github.com/repos/clue/arguments/zipball/87f2c4bc2ff602173bc52f5935a9c3b70d8c996d",
+ "reference": "87f2c4bc2ff602173bc52f5935a9c3b70d8c996d",
"shasum": ""
},
"require": {
"php": ">=5.3"
},
+ "require-dev": {
+ "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35"
+ },
"type": "library",
"autoload": {
"files": [
@@ -40,11 +43,11 @@
"authors": [
{
"name": "Christian Lück",
- "email": "christian@lueck.tv"
+ "email": "christian@clue.engineering"
}
],
"description": "The simple way to split your command line string into an array of command arguments in PHP.",
- "homepage": "https://github.com/clue/php-arguments",
+ "homepage": "https://github.com/clue/arguments",
"keywords": [
"args",
"arguments",
@@ -55,25 +58,39 @@
"parse",
"split"
],
- "time": "2016-12-18T14:37:39+00:00"
+ "support": {
+ "issues": "https://github.com/clue/arguments/issues",
+ "source": "https://github.com/clue/arguments/tree/v2.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://clue.engineering/support",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/clue",
+ "type": "github"
+ }
+ ],
+ "time": "2020-12-08T13:02:50+00:00"
},
{
"name": "dms/phpunit-arraysubset-asserts",
- "version": "v0.1.1",
+ "version": "v0.2.1",
"source": {
"type": "git",
"url": "https://github.com/rdohms/phpunit-arraysubset-asserts.git",
- "reference": "1fc5a0f3db1d0c440a7c6b8834917888247f8f42"
+ "reference": "8e3673a70019a60df484e36fc3271d63cbdc40ea"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/rdohms/phpunit-arraysubset-asserts/zipball/1fc5a0f3db1d0c440a7c6b8834917888247f8f42",
- "reference": "1fc5a0f3db1d0c440a7c6b8834917888247f8f42",
+ "url": "https://api.github.com/repos/rdohms/phpunit-arraysubset-asserts/zipball/8e3673a70019a60df484e36fc3271d63cbdc40ea",
+ "reference": "8e3673a70019a60df484e36fc3271d63cbdc40ea",
"shasum": ""
},
"require": {
- "php": "^7.2",
- "phpunit/phpunit": "^8.4"
+ "php": "^7.3|^8.0",
+ "phpunit/phpunit": "^9.0"
},
"require-dev": {
"dms/coding-standard": "^1.0",
@@ -95,41 +112,40 @@
"email": "rdohms@gmail.com"
}
],
- "description": "This package provides Array Subset and related asserts once depracated in PHPunit 8",
- "time": "2020-02-18T21:20:04+00:00"
+ "description": "This package provides ArraySubset and related asserts once deprecated in PHPUnit 8",
+ "support": {
+ "issues": "https://github.com/rdohms/phpunit-arraysubset-asserts/issues",
+ "source": "https://github.com/rdohms/phpunit-arraysubset-asserts/tree/v0.2.1"
+ },
+ "time": "2020-10-03T21:43:40+00:00"
},
{
"name": "doctrine/instantiator",
- "version": "1.3.1",
+ "version": "1.4.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/instantiator.git",
- "reference": "f350df0268e904597e3bd9c4685c53e0e333feea"
+ "reference": "d56bf6102915de5702778fe20f2de3b2fe570b5b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/instantiator/zipball/f350df0268e904597e3bd9c4685c53e0e333feea",
- "reference": "f350df0268e904597e3bd9c4685c53e0e333feea",
+ "url": "https://api.github.com/repos/doctrine/instantiator/zipball/d56bf6102915de5702778fe20f2de3b2fe570b5b",
+ "reference": "d56bf6102915de5702778fe20f2de3b2fe570b5b",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
- "doctrine/coding-standard": "^6.0",
+ "doctrine/coding-standard": "^8.0",
"ext-pdo": "*",
"ext-phar": "*",
- "phpbench/phpbench": "^0.13",
- "phpstan/phpstan-phpunit": "^0.11",
- "phpstan/phpstan-shim": "^0.11",
- "phpunit/phpunit": "^7.0"
+ "phpbench/phpbench": "^0.13 || 1.0.0-alpha2",
+ "phpstan/phpstan": "^0.12",
+ "phpstan/phpstan-phpunit": "^0.12",
+ "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.2.x-dev"
- }
- },
"autoload": {
"psr-4": {
"Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
@@ -143,7 +159,7 @@
{
"name": "Marco Pivetta",
"email": "ocramius@gmail.com",
- "homepage": "http://ocramius.github.com/"
+ "homepage": "https://ocramius.github.io/"
}
],
"description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
@@ -152,6 +168,10 @@
"constructor",
"instantiate"
],
+ "support": {
+ "issues": "https://github.com/doctrine/instantiator/issues",
+ "source": "https://github.com/doctrine/instantiator/tree/1.4.0"
+ },
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
@@ -166,7 +186,154 @@
"type": "tidelift"
}
],
- "time": "2020-05-29T17:27:14+00:00"
+ "time": "2020-11-10T18:47:58+00:00"
+ },
+ {
+ "name": "eloquent/phony",
+ "version": "5.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/eloquent/phony.git",
+ "reference": "f34d67d6db6b6f351ea7e8aa8066107e756ec26b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/eloquent/phony/zipball/f34d67d6db6b6f351ea7e8aa8066107e756ec26b",
+ "reference": "f34d67d6db6b6f351ea7e8aa8066107e756ec26b",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.3 || ^8"
+ },
+ "require-dev": {
+ "eloquent/code-style": "^1.0",
+ "eloquent/phpstan-phony": "^0.7",
+ "errors/exceptions": "^0.2",
+ "ext-pdo": "*",
+ "friendsofphp/php-cs-fixer": "^2",
+ "hamcrest/hamcrest-php": "^2",
+ "phpstan/extension-installer": "^1",
+ "phpstan/phpstan": "^0.12",
+ "phpstan/phpstan-phpunit": "^0.12",
+ "phpunit/phpunit": "^9"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Eloquent\\Phony\\": "src"
+ },
+ "files": [
+ "src/initialize.php",
+ "src/functions.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Erin Millard",
+ "email": "ezzatron@gmail.com",
+ "homepage": "http://ezzatron.com/"
+ }
+ ],
+ "description": "Mocks, stubs, and spies for PHP.",
+ "homepage": "http://eloquent-software.com/phony/",
+ "keywords": [
+ "Double",
+ "Dummy",
+ "fake",
+ "mock",
+ "mocking",
+ "spy",
+ "stub",
+ "stubbing",
+ "test"
+ ],
+ "support": {
+ "issues": "https://github.com/eloquent/phony/issues",
+ "source": "https://github.com/eloquent/phony/tree/5.0.2"
+ },
+ "time": "2021-02-17T01:45:10+00:00"
+ },
+ {
+ "name": "eloquent/phony-phpunit",
+ "version": "7.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/eloquent/phony-phpunit.git",
+ "reference": "e77ff95ea6235211d4aae7e5f53488a5faebc2e0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/eloquent/phony-phpunit/zipball/e77ff95ea6235211d4aae7e5f53488a5faebc2e0",
+ "reference": "e77ff95ea6235211d4aae7e5f53488a5faebc2e0",
+ "shasum": ""
+ },
+ "require": {
+ "eloquent/phony": "^5",
+ "php": "^7.3 || ^8",
+ "phpunit/phpunit": "^9"
+ },
+ "require-dev": {
+ "eloquent/code-style": "^1",
+ "eloquent/phpstan-phony": "^0.7",
+ "errors/exceptions": "^0.2",
+ "friendsofphp/php-cs-fixer": "^2",
+ "phpstan/extension-installer": "^1",
+ "phpstan/phpstan": "^0.12",
+ "phpstan/phpstan-phpunit": "^0.12"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "7.2.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Eloquent\\Phony\\Phpunit\\": "src"
+ },
+ "files": [
+ "src/initialize.php",
+ "src/functions.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Erin Millard",
+ "email": "ezzatron@gmail.com",
+ "homepage": "http://ezzatron.com/"
+ }
+ ],
+ "description": "Phony for PHPUnit.",
+ "homepage": "http://eloquent-software.com/phony/",
+ "keywords": [
+ "Double",
+ "Dummy",
+ "fake",
+ "mock",
+ "mocking",
+ "spy",
+ "stub",
+ "stubbing",
+ "test"
+ ],
+ "support": {
+ "issues": "https://github.com/eloquent/phony-phpunit/issues",
+ "source": "https://github.com/eloquent/phony-phpunit/tree/7.1.0"
+ },
+ "time": "2020-12-21T09:36:47+00:00"
},
{
"name": "mikey179/vfsstream",
@@ -212,20 +379,25 @@
],
"description": "Virtual file system to mock the real file system in unit tests.",
"homepage": "http://vfs.bovigo.org/",
+ "support": {
+ "issues": "https://github.com/bovigo/vfsStream/issues",
+ "source": "https://github.com/bovigo/vfsStream/tree/master",
+ "wiki": "https://github.com/bovigo/vfsStream/wiki"
+ },
"time": "2019-10-30T15:31:00+00:00"
},
{
"name": "myclabs/deep-copy",
- "version": "1.10.1",
+ "version": "1.10.2",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
- "reference": "969b211f9a51aa1f6c01d1d2aef56d3bd91598e5"
+ "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/969b211f9a51aa1f6c01d1d2aef56d3bd91598e5",
- "reference": "969b211f9a51aa1f6c01d1d2aef56d3bd91598e5",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/776f831124e9c62e1a2c601ecc52e776d8bb7220",
+ "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220",
"shasum": ""
},
"require": {
@@ -260,52 +432,52 @@
"object",
"object graph"
],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.10.2"
+ },
"funding": [
{
"url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
"type": "tidelift"
}
],
- "time": "2020-06-29T13:22:24+00:00"
+ "time": "2020-11-13T09:40:50+00:00"
},
{
- "name": "phake/phake",
- "version": "v3.1.8",
+ "name": "nikic/php-parser",
+ "version": "v4.11.0",
"source": {
"type": "git",
- "url": "https://github.com/mlively/Phake.git",
- "reference": "9f9dfb12c9ce6e38c73de9631ea2ab0f0ea36a65"
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "fe14cf3672a149364fb66dfe11bf6549af899f94"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/mlively/Phake/zipball/9f9dfb12c9ce6e38c73de9631ea2ab0f0ea36a65",
- "reference": "9f9dfb12c9ce6e38c73de9631ea2ab0f0ea36a65",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/fe14cf3672a149364fb66dfe11bf6549af899f94",
+ "reference": "fe14cf3672a149364fb66dfe11bf6549af899f94",
"shasum": ""
},
"require": {
- "php": ">=7",
- "sebastian/comparator": "^1.1|^2.0|^3.0|^4.0"
+ "ext-tokenizer": "*",
+ "php": ">=7.0"
},
"require-dev": {
- "codeclimate/php-test-reporter": "dev-master",
- "doctrine/common": "2.3.*",
- "ext-soap": "*",
- "hamcrest/hamcrest-php": "1.1.*",
- "phpunit/phpunit": "^7.0"
- },
- "suggest": {
- "doctrine/common": "Allows mock annotations to use import statements for classes.",
- "hamcrest/hamcrest-php": "Use Hamcrest matchers."
+ "ircmaxell/php-yacc": "^0.0.7",
+ "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0"
},
+ "bin": [
+ "bin/php-parse"
+ ],
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "3.0.0-dev"
+ "dev-master": "4.9-dev"
}
},
"autoload": {
- "psr-0": {
- "Phake": "src/"
+ "psr-4": {
+ "PhpParser\\": "lib/PhpParser"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -314,42 +486,45 @@
],
"authors": [
{
- "name": "Mike Lively",
- "email": "m@digitalsandwich.com"
+ "name": "Nikita Popov"
}
],
- "description": "The Phake mock testing library",
- "homepage": "https://github.com/mlively/Phake",
+ "description": "A PHP parser written in PHP",
"keywords": [
- "mock",
- "testing"
+ "parser",
+ "php"
],
- "time": "2020-05-11T18:43:26+00:00"
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v4.11.0"
+ },
+ "time": "2021-07-03T13:36:55+00:00"
},
{
"name": "phar-io/manifest",
- "version": "1.0.3",
+ "version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/phar-io/manifest.git",
- "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4"
+ "reference": "85265efd3af7ba3ca4b2a2c34dbfc5788dd29133"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phar-io/manifest/zipball/7761fcacf03b4d4f16e7ccb606d4879ca431fcf4",
- "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/85265efd3af7ba3ca4b2a2c34dbfc5788dd29133",
+ "reference": "85265efd3af7ba3ca4b2a2c34dbfc5788dd29133",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-phar": "*",
- "phar-io/version": "^2.0",
- "php": "^5.6 || ^7.0"
+ "ext-xmlwriter": "*",
+ "phar-io/version": "^3.0.1",
+ "php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.0.x-dev"
+ "dev-master": "2.0.x-dev"
}
},
"autoload": {
@@ -379,24 +554,28 @@
}
],
"description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
- "time": "2018-07-08T19:23:20+00:00"
+ "support": {
+ "issues": "https://github.com/phar-io/manifest/issues",
+ "source": "https://github.com/phar-io/manifest/tree/master"
+ },
+ "time": "2020-06-27T14:33:11+00:00"
},
{
"name": "phar-io/version",
- "version": "2.0.1",
+ "version": "3.1.0",
"source": {
"type": "git",
"url": "https://github.com/phar-io/version.git",
- "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6"
+ "reference": "bae7c545bef187884426f042434e561ab1ddb182"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phar-io/version/zipball/45a2ec53a73c70ce41d55cedef9063630abaf1b6",
- "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/bae7c545bef187884426f042434e561ab1ddb182",
+ "reference": "bae7c545bef187884426f042434e561ab1ddb182",
"shasum": ""
},
"require": {
- "php": "^5.6 || ^7.0"
+ "php": "^7.2 || ^8.0"
},
"type": "library",
"autoload": {
@@ -426,7 +605,11 @@
}
],
"description": "Library for handling version information and constraints",
- "time": "2018-07-08T19:19:57+00:00"
+ "support": {
+ "issues": "https://github.com/phar-io/version/issues",
+ "source": "https://github.com/phar-io/version/tree/3.1.0"
+ },
+ "time": "2021-02-23T14:00:09+00:00"
},
{
"name": "phpdocumentor/reflection-common",
@@ -475,6 +658,10 @@
"reflection",
"static analysis"
],
+ "support": {
+ "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
+ "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x"
+ },
"time": "2020-06-27T09:03:43+00:00"
},
{
@@ -527,6 +714,10 @@
}
],
"description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
+ "support": {
+ "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
+ "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/master"
+ },
"time": "2020-09-03T19:13:55+00:00"
},
{
@@ -572,20 +763,24 @@
}
],
"description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
+ "support": {
+ "issues": "https://github.com/phpDocumentor/TypeResolver/issues",
+ "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.4.0"
+ },
"time": "2020-09-17T18:55:26+00:00"
},
{
"name": "phpspec/prophecy",
- "version": "1.12.1",
+ "version": "1.13.0",
"source": {
"type": "git",
"url": "https://github.com/phpspec/prophecy.git",
- "reference": "8ce87516be71aae9b956f81906aaf0338e0d8a2d"
+ "reference": "be1996ed8adc35c3fd795488a653f4b518be70ea"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpspec/prophecy/zipball/8ce87516be71aae9b956f81906aaf0338e0d8a2d",
- "reference": "8ce87516be71aae9b956f81906aaf0338e0d8a2d",
+ "url": "https://api.github.com/repos/phpspec/prophecy/zipball/be1996ed8adc35c3fd795488a653f4b518be70ea",
+ "reference": "be1996ed8adc35c3fd795488a653f4b518be70ea",
"shasum": ""
},
"require": {
@@ -597,7 +792,7 @@
},
"require-dev": {
"phpspec/phpspec": "^6.0",
- "phpunit/phpunit": "^8.0 || ^9.0 <9.3"
+ "phpunit/phpunit": "^8.0 || ^9.0"
},
"type": "library",
"extra": {
@@ -635,44 +830,52 @@
"spy",
"stub"
],
- "time": "2020-09-29T09:10:42+00:00"
+ "support": {
+ "issues": "https://github.com/phpspec/prophecy/issues",
+ "source": "https://github.com/phpspec/prophecy/tree/1.13.0"
+ },
+ "time": "2021-03-17T13:42:18+00:00"
},
{
"name": "phpunit/php-code-coverage",
- "version": "7.0.10",
+ "version": "9.2.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
- "reference": "f1884187926fbb755a9aaf0b3836ad3165b478bf"
+ "reference": "f6293e1b30a2354e8428e004689671b83871edde"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f1884187926fbb755a9aaf0b3836ad3165b478bf",
- "reference": "f1884187926fbb755a9aaf0b3836ad3165b478bf",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f6293e1b30a2354e8428e004689671b83871edde",
+ "reference": "f6293e1b30a2354e8428e004689671b83871edde",
"shasum": ""
},
"require": {
"ext-dom": "*",
+ "ext-libxml": "*",
"ext-xmlwriter": "*",
- "php": "^7.2",
- "phpunit/php-file-iterator": "^2.0.2",
- "phpunit/php-text-template": "^1.2.1",
- "phpunit/php-token-stream": "^3.1.1",
- "sebastian/code-unit-reverse-lookup": "^1.0.1",
- "sebastian/environment": "^4.2.2",
- "sebastian/version": "^2.0.1",
- "theseer/tokenizer": "^1.1.3"
+ "nikic/php-parser": "^4.10.2",
+ "php": ">=7.3",
+ "phpunit/php-file-iterator": "^3.0.3",
+ "phpunit/php-text-template": "^2.0.2",
+ "sebastian/code-unit-reverse-lookup": "^2.0.2",
+ "sebastian/complexity": "^2.0",
+ "sebastian/environment": "^5.1.2",
+ "sebastian/lines-of-code": "^1.0.3",
+ "sebastian/version": "^3.0.1",
+ "theseer/tokenizer": "^1.2.0"
},
"require-dev": {
- "phpunit/phpunit": "^8.2.2"
+ "phpunit/phpunit": "^9.3"
},
"suggest": {
- "ext-xdebug": "^2.7.2"
+ "ext-pcov": "*",
+ "ext-xdebug": "*"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "7.0-dev"
+ "dev-master": "9.2-dev"
}
},
"autoload": {
@@ -698,32 +901,42 @@
"testing",
"xunit"
],
- "time": "2019-11-20T13:55:58+00:00"
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2021-03-28T07:26:59+00:00"
},
{
"name": "phpunit/php-file-iterator",
- "version": "2.0.2",
+ "version": "3.0.5",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
- "reference": "050bedf145a257b1ff02746c31894800e5122946"
+ "reference": "aa4be8575f26070b100fccb67faabb28f21f66f8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/050bedf145a257b1ff02746c31894800e5122946",
- "reference": "050bedf145a257b1ff02746c31894800e5122946",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/aa4be8575f26070b100fccb67faabb28f21f66f8",
+ "reference": "aa4be8575f26070b100fccb67faabb28f21f66f8",
"shasum": ""
},
"require": {
- "php": "^7.1"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^7.1"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.0.x-dev"
+ "dev-master": "3.0-dev"
}
},
"autoload": {
@@ -748,26 +961,107 @@
"filesystem",
"iterator"
],
- "time": "2018-09-13T20:33:42+00:00"
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T05:57:25+00:00"
},
{
- "name": "phpunit/php-text-template",
- "version": "1.2.1",
+ "name": "phpunit/php-invoker",
+ "version": "3.1.1",
"source": {
"type": "git",
- "url": "https://github.com/sebastianbergmann/php-text-template.git",
- "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686"
+ "url": "https://github.com/sebastianbergmann/php-invoker.git",
+ "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686",
- "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+ "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
"shasum": ""
},
"require": {
- "php": ">=5.3.3"
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "ext-pcntl": "*",
+ "phpunit/phpunit": "^9.3"
+ },
+ "suggest": {
+ "ext-pcntl": "*"
},
"type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Invoke callables with a timeout",
+ "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+ "keywords": [
+ "process"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T05:58:55+00:00"
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+ "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
"autoload": {
"classmap": [
"src/"
@@ -789,32 +1083,42 @@
"keywords": [
"template"
],
- "time": "2015-06-21T13:50:34+00:00"
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T05:33:50+00:00"
},
{
"name": "phpunit/php-timer",
- "version": "2.1.2",
+ "version": "5.0.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-timer.git",
- "reference": "1038454804406b0b5f5f520358e78c1c2f71501e"
+ "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/1038454804406b0b5f5f520358e78c1c2f71501e",
- "reference": "1038454804406b0b5f5f520358e78c1c2f71501e",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+ "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
"shasum": ""
},
"require": {
- "php": "^7.1"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^7.0"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.1-dev"
+ "dev-master": "5.0-dev"
}
},
"autoload": {
@@ -838,106 +1142,69 @@
"keywords": [
"timer"
],
- "time": "2019-06-07T04:22:29+00:00"
- },
- {
- "name": "phpunit/php-token-stream",
- "version": "3.1.1",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/php-token-stream.git",
- "reference": "995192df77f63a59e47f025390d2d1fdf8f425ff"
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3"
},
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/995192df77f63a59e47f025390d2d1fdf8f425ff",
- "reference": "995192df77f63a59e47f025390d2d1fdf8f425ff",
- "shasum": ""
- },
- "require": {
- "ext-tokenizer": "*",
- "php": "^7.1"
- },
- "require-dev": {
- "phpunit/phpunit": "^7.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "3.1-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
+ "funding": [
{
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
}
],
- "description": "Wrapper around PHP's tokenizer extension.",
- "homepage": "https://github.com/sebastianbergmann/php-token-stream/",
- "keywords": [
- "tokenizer"
- ],
- "abandoned": true,
- "time": "2019-09-17T06:23:10+00:00"
+ "time": "2020-10-26T13:16:10+00:00"
},
{
"name": "phpunit/phpunit",
- "version": "8.5.8",
+ "version": "9.5.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "34c18baa6a44f1d1fbf0338907139e9dce95b997"
+ "reference": "fb9b8333f14e3dce976a60ef6a7e05c7c7ed8bfb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/34c18baa6a44f1d1fbf0338907139e9dce95b997",
- "reference": "34c18baa6a44f1d1fbf0338907139e9dce95b997",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fb9b8333f14e3dce976a60ef6a7e05c7c7ed8bfb",
+ "reference": "fb9b8333f14e3dce976a60ef6a7e05c7c7ed8bfb",
"shasum": ""
},
"require": {
- "doctrine/instantiator": "^1.2.0",
+ "doctrine/instantiator": "^1.3.1",
"ext-dom": "*",
"ext-json": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-xml": "*",
"ext-xmlwriter": "*",
- "myclabs/deep-copy": "^1.9.1",
- "phar-io/manifest": "^1.0.3",
- "phar-io/version": "^2.0.1",
- "php": "^7.2",
- "phpspec/prophecy": "^1.8.1",
- "phpunit/php-code-coverage": "^7.0.7",
- "phpunit/php-file-iterator": "^2.0.2",
- "phpunit/php-text-template": "^1.2.1",
- "phpunit/php-timer": "^2.1.2",
- "sebastian/comparator": "^3.0.2",
- "sebastian/diff": "^3.0.2",
- "sebastian/environment": "^4.2.2",
- "sebastian/exporter": "^3.1.1",
- "sebastian/global-state": "^3.0.0",
- "sebastian/object-enumerator": "^3.0.3",
- "sebastian/resource-operations": "^2.0.1",
- "sebastian/type": "^1.1.3",
- "sebastian/version": "^2.0.1"
+ "myclabs/deep-copy": "^1.10.1",
+ "phar-io/manifest": "^2.0.1",
+ "phar-io/version": "^3.0.2",
+ "php": ">=7.3",
+ "phpspec/prophecy": "^1.12.1",
+ "phpunit/php-code-coverage": "^9.2.3",
+ "phpunit/php-file-iterator": "^3.0.5",
+ "phpunit/php-invoker": "^3.1.1",
+ "phpunit/php-text-template": "^2.0.3",
+ "phpunit/php-timer": "^5.0.2",
+ "sebastian/cli-parser": "^1.0.1",
+ "sebastian/code-unit": "^1.0.6",
+ "sebastian/comparator": "^4.0.5",
+ "sebastian/diff": "^4.0.3",
+ "sebastian/environment": "^5.1.3",
+ "sebastian/exporter": "^4.0.3",
+ "sebastian/global-state": "^5.0.1",
+ "sebastian/object-enumerator": "^4.0.3",
+ "sebastian/resource-operations": "^3.0.3",
+ "sebastian/type": "^2.3.4",
+ "sebastian/version": "^3.0.2"
},
"require-dev": {
- "ext-pdo": "*"
+ "ext-pdo": "*",
+ "phpspec/prophecy-phpunit": "^2.0.1"
},
"suggest": {
"ext-soap": "*",
- "ext-xdebug": "*",
- "phpunit/php-invoker": "^2.0.0"
+ "ext-xdebug": "*"
},
"bin": [
"phpunit"
@@ -945,12 +1212,15 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "8.5-dev"
+ "dev-master": "9.5-dev"
}
},
"autoload": {
"classmap": [
"src/"
+ ],
+ "files": [
+ "src/Framework/Assert/Functions.php"
]
},
"notification-url": "https://packagist.org/downloads/",
@@ -971,6 +1241,10 @@
"testing",
"xunit"
],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.6"
+ },
"funding": [
{
"url": "https://phpunit.de/donate.html",
@@ -981,32 +1255,144 @@
"type": "github"
}
],
- "time": "2020-06-22T07:06:58+00:00"
+ "time": "2021-06-23T05:14:38+00:00"
},
{
- "name": "sebastian/code-unit-reverse-lookup",
+ "name": "sebastian/cli-parser",
"version": "1.0.1",
"source": {
"type": "git",
- "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
- "reference": "4419fcdb5eabb9caa61a27c7a1db532a6b55dd18"
+ "url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/4419fcdb5eabb9caa61a27c7a1db532a6b55dd18",
- "reference": "4419fcdb5eabb9caa61a27c7a1db532a6b55dd18",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/442e7c7e687e42adc03470c7b668bc4b2402c0b2",
+ "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2",
"shasum": ""
},
"require": {
- "php": "^5.6 || ^7.0"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^5.7 || ^6.0"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.0.x-dev"
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for parsing CLI options",
+ "homepage": "https://github.com/sebastianbergmann/cli-parser",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T06:08:49+00:00"
+ },
+ {
+ "name": "sebastian/code-unit",
+ "version": "1.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit.git",
+ "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120",
+ "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/code-unit",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:08:54+00:00"
+ },
+ {
+ "name": "sebastian/code-unit-reverse-lookup",
+ "version": "2.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+ "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+ "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
}
},
"autoload": {
@@ -1026,34 +1412,44 @@
],
"description": "Looks up which function or method a line of code belongs to",
"homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
- "time": "2017-03-04T06:30:41+00:00"
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T05:30:19+00:00"
},
{
"name": "sebastian/comparator",
- "version": "3.0.2",
+ "version": "4.0.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
- "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da"
+ "reference": "55f4261989e546dc112258c7a75935a81a7ce382"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/5de4fc177adf9bce8df98d8d141a7559d7ccf6da",
- "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55f4261989e546dc112258c7a75935a81a7ce382",
+ "reference": "55f4261989e546dc112258c7a75935a81a7ce382",
"shasum": ""
},
"require": {
- "php": "^7.1",
- "sebastian/diff": "^3.0",
- "sebastian/exporter": "^3.1"
+ "php": ">=7.3",
+ "sebastian/diff": "^4.0",
+ "sebastian/exporter": "^4.0"
},
"require-dev": {
- "phpunit/phpunit": "^7.1"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "3.0-dev"
+ "dev-master": "4.0-dev"
}
},
"autoload": {
@@ -1066,6 +1462,10 @@
"BSD-3-Clause"
],
"authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
{
"name": "Jeff Welch",
"email": "whatthejeff@gmail.com"
@@ -1077,10 +1477,6 @@
{
"name": "Bernhard Schussek",
"email": "bschussek@2bepublished.at"
- },
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
}
],
"description": "Provides the functionality to compare PHP values for equality",
@@ -1090,33 +1486,43 @@
"compare",
"equality"
],
- "time": "2018-07-12T15:12:46+00:00"
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/comparator/issues",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T15:49:45+00:00"
},
{
- "name": "sebastian/diff",
- "version": "3.0.2",
+ "name": "sebastian/complexity",
+ "version": "2.0.2",
"source": {
"type": "git",
- "url": "https://github.com/sebastianbergmann/diff.git",
- "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29"
+ "url": "https://github.com/sebastianbergmann/complexity.git",
+ "reference": "739b35e53379900cc9ac327b2147867b8b6efd88"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/720fcc7e9b5cf384ea68d9d930d480907a0c1a29",
- "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88",
+ "reference": "739b35e53379900cc9ac327b2147867b8b6efd88",
"shasum": ""
},
"require": {
- "php": "^7.1"
+ "nikic/php-parser": "^4.7",
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^7.5 || ^8.0",
- "symfony/process": "^2 || ^3.3 || ^4"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "3.0-dev"
+ "dev-master": "2.0-dev"
}
},
"autoload": {
@@ -1130,12 +1536,69 @@
],
"authors": [
{
- "name": "Kore Nordmann",
- "email": "mail@kore-nordmann.de"
- },
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for calculating the complexity of PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/complexity",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/complexity/issues",
+ "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T15:52:27+00:00"
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "4.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/3461e3fccc7cfdfc2720be910d3bd73c69be590d",
+ "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3",
+ "symfony/process": "^4.2 || ^5"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
{
"name": "Sebastian Bergmann",
"email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
}
],
"description": "Diff implementation",
@@ -1146,27 +1609,37 @@
"unidiff",
"unified diff"
],
- "time": "2019-02-04T06:01:07+00:00"
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/diff/issues",
+ "source": "https://github.com/sebastianbergmann/diff/tree/4.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:10:38+00:00"
},
{
"name": "sebastian/environment",
- "version": "4.2.3",
+ "version": "5.1.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/environment.git",
- "reference": "464c90d7bdf5ad4e8a6aea15c091fec0603d4368"
+ "reference": "388b6ced16caa751030f6a69e588299fa09200ac"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/464c90d7bdf5ad4e8a6aea15c091fec0603d4368",
- "reference": "464c90d7bdf5ad4e8a6aea15c091fec0603d4368",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/388b6ced16caa751030f6a69e588299fa09200ac",
+ "reference": "388b6ced16caa751030f6a69e588299fa09200ac",
"shasum": ""
},
"require": {
- "php": "^7.1"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^7.5"
+ "phpunit/phpunit": "^9.3"
},
"suggest": {
"ext-posix": "*"
@@ -1174,7 +1647,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.2-dev"
+ "dev-master": "5.1-dev"
}
},
"autoload": {
@@ -1199,34 +1672,44 @@
"environment",
"hhvm"
],
- "time": "2019-11-20T08:46:58+00:00"
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/environment/issues",
+ "source": "https://github.com/sebastianbergmann/environment/tree/5.1.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T05:52:38+00:00"
},
{
"name": "sebastian/exporter",
- "version": "3.1.2",
+ "version": "4.0.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/exporter.git",
- "reference": "68609e1261d215ea5b21b7987539cbfbe156ec3e"
+ "reference": "d89cc98761b8cb5a1a235a6b703ae50d34080e65"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/68609e1261d215ea5b21b7987539cbfbe156ec3e",
- "reference": "68609e1261d215ea5b21b7987539cbfbe156ec3e",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/d89cc98761b8cb5a1a235a6b703ae50d34080e65",
+ "reference": "d89cc98761b8cb5a1a235a6b703ae50d34080e65",
"shasum": ""
},
"require": {
- "php": "^7.0",
- "sebastian/recursion-context": "^3.0"
+ "php": ">=7.3",
+ "sebastian/recursion-context": "^4.0"
},
"require-dev": {
"ext-mbstring": "*",
- "phpunit/phpunit": "^6.0"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "3.1.x-dev"
+ "dev-master": "4.0-dev"
}
},
"autoload": {
@@ -1266,30 +1749,40 @@
"export",
"exporter"
],
- "time": "2019-09-14T09:02:43+00:00"
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/exporter/issues",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T05:24:23+00:00"
},
{
"name": "sebastian/global-state",
- "version": "3.0.0",
+ "version": "5.0.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/global-state.git",
- "reference": "edf8a461cf1d4005f19fb0b6b8b95a9f7fa0adc4"
+ "reference": "23bd5951f7ff26f12d4e3242864df3e08dec4e49"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/edf8a461cf1d4005f19fb0b6b8b95a9f7fa0adc4",
- "reference": "edf8a461cf1d4005f19fb0b6b8b95a9f7fa0adc4",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/23bd5951f7ff26f12d4e3242864df3e08dec4e49",
+ "reference": "23bd5951f7ff26f12d4e3242864df3e08dec4e49",
"shasum": ""
},
"require": {
- "php": "^7.2",
- "sebastian/object-reflector": "^1.1.1",
- "sebastian/recursion-context": "^3.0"
+ "php": ">=7.3",
+ "sebastian/object-reflector": "^2.0",
+ "sebastian/recursion-context": "^4.0"
},
"require-dev": {
"ext-dom": "*",
- "phpunit/phpunit": "^8.0"
+ "phpunit/phpunit": "^9.3"
},
"suggest": {
"ext-uopz": "*"
@@ -1297,7 +1790,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "3.0-dev"
+ "dev-master": "5.0-dev"
}
},
"autoload": {
@@ -1320,34 +1813,101 @@
"keywords": [
"global state"
],
- "time": "2019-02-01T05:30:01+00:00"
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/global-state/issues",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2021-06-11T13:31:12+00:00"
},
{
- "name": "sebastian/object-enumerator",
- "version": "3.0.3",
+ "name": "sebastian/lines-of-code",
+ "version": "1.0.3",
"source": {
"type": "git",
- "url": "https://github.com/sebastianbergmann/object-enumerator.git",
- "reference": "7cfd9e65d11ffb5af41198476395774d4c8a84c5"
+ "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+ "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/7cfd9e65d11ffb5af41198476395774d4c8a84c5",
- "reference": "7cfd9e65d11ffb5af41198476395774d4c8a84c5",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc",
+ "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc",
"shasum": ""
},
"require": {
- "php": "^7.0",
- "sebastian/object-reflector": "^1.1.1",
- "sebastian/recursion-context": "^3.0"
+ "nikic/php-parser": "^4.6",
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^6.0"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "3.0.x-dev"
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for counting the lines of code in PHP source code",
+ "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-11-28T06:42:11+00:00"
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "4.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "5c9eeac41b290a3712d88851518825ad78f45c71"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71",
+ "reference": "5c9eeac41b290a3712d88851518825ad78f45c71",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "sebastian/object-reflector": "^2.0",
+ "sebastian/recursion-context": "^4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
}
},
"autoload": {
@@ -1367,122 +1927,37 @@
],
"description": "Traverses array structures and object graphs to enumerate all referenced objects",
"homepage": "https://github.com/sebastianbergmann/object-enumerator/",
- "time": "2017-08-03T12:35:26+00:00"
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:12:34+00:00"
},
{
"name": "sebastian/object-reflector",
- "version": "1.1.1",
+ "version": "2.0.4",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/object-reflector.git",
- "reference": "773f97c67f28de00d397be301821b06708fca0be"
+ "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/773f97c67f28de00d397be301821b06708fca0be",
- "reference": "773f97c67f28de00d397be301821b06708fca0be",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+ "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
"shasum": ""
},
"require": {
- "php": "^7.0"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^6.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.1-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
- }
- ],
- "description": "Allows reflection of object attributes, including inherited and non-public ones",
- "homepage": "https://github.com/sebastianbergmann/object-reflector/",
- "time": "2017-03-29T09:07:27+00:00"
- },
- {
- "name": "sebastian/recursion-context",
- "version": "3.0.0",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/recursion-context.git",
- "reference": "5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8",
- "reference": "5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8",
- "shasum": ""
- },
- "require": {
- "php": "^7.0"
- },
- "require-dev": {
- "phpunit/phpunit": "^6.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "3.0.x-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Jeff Welch",
- "email": "whatthejeff@gmail.com"
- },
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
- },
- {
- "name": "Adam Harvey",
- "email": "aharvey@php.net"
- }
- ],
- "description": "Provides functionality to recursively process PHP variables",
- "homepage": "http://www.github.com/sebastianbergmann/recursion-context",
- "time": "2017-03-03T06:23:57+00:00"
- },
- {
- "name": "sebastian/resource-operations",
- "version": "2.0.1",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/resource-operations.git",
- "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/4d7a795d35b889bf80a0cc04e08d77cedfa917a9",
- "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9",
- "shasum": ""
- },
- "require": {
- "php": "^7.1"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
@@ -1505,34 +1980,162 @@
"email": "sebastian@phpunit.de"
}
],
- "description": "Provides a list of PHP built-in functions that operate on resources",
- "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
- "time": "2018-10-04T04:07:39+00:00"
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:14:26+00:00"
},
{
- "name": "sebastian/type",
- "version": "1.1.3",
+ "name": "sebastian/recursion-context",
+ "version": "4.0.4",
"source": {
"type": "git",
- "url": "https://github.com/sebastianbergmann/type.git",
- "reference": "3aaaa15fa71d27650d62a948be022fe3b48541a3"
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/3aaaa15fa71d27650d62a948be022fe3b48541a3",
- "reference": "3aaaa15fa71d27650d62a948be022fe3b48541a3",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/cd9d8cf3c5804de4341c283ed787f099f5506172",
+ "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172",
"shasum": ""
},
"require": {
- "php": "^7.2"
+ "php": ">=7.3"
},
"require-dev": {
- "phpunit/phpunit": "^8.2"
+ "phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.1-dev"
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides functionality to recursively process PHP variables",
+ "homepage": "http://www.github.com/sebastianbergmann/recursion-context",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:17:30+00:00"
+ },
+ {
+ "name": "sebastian/resource-operations",
+ "version": "3.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/resource-operations.git",
+ "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8",
+ "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides a list of PHP built-in functions that operate on resources",
+ "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/resource-operations/issues",
+ "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T06:45:17+00:00"
+ },
+ {
+ "name": "sebastian/type",
+ "version": "2.3.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "b8cd8a1c753c90bc1a0f5372170e3e489136f914"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/b8cd8a1c753c90bc1a0f5372170e3e489136f914",
+ "reference": "b8cd8a1c753c90bc1a0f5372170e3e489136f914",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.3-dev"
}
},
"autoload": {
@@ -1553,29 +2156,39 @@
],
"description": "Collection of value objects that represent the types of the PHP type system",
"homepage": "https://github.com/sebastianbergmann/type",
- "time": "2019-07-02T08:10:15+00:00"
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "source": "https://github.com/sebastianbergmann/type/tree/2.3.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2021-06-15T12:49:02+00:00"
},
{
"name": "sebastian/version",
- "version": "2.0.1",
+ "version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/version.git",
- "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019"
+ "reference": "c6c1022351a901512170118436c764e473f6de8c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/99732be0ddb3361e16ad77b68ba41efc8e979019",
- "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c",
+ "reference": "c6c1022351a901512170118436c764e473f6de8c",
"shasum": ""
},
"require": {
- "php": ">=5.6"
+ "php": ">=7.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.0.x-dev"
+ "dev-master": "3.0-dev"
}
},
"autoload": {
@@ -1596,20 +2209,30 @@
],
"description": "Library that helps with managing the version number of Git-hosted PHP projects",
"homepage": "https://github.com/sebastianbergmann/version",
- "time": "2016-10-03T07:35:21+00:00"
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/version/issues",
+ "source": "https://github.com/sebastianbergmann/version/tree/3.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T06:39:44+00:00"
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
- "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41"
+ "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f4ba089a5b6366e453971d3aad5fe8e897b37f41",
- "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/46cd95797e9df938fdd2b03693b5fca5e64b01ce",
+ "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce",
"shasum": ""
},
"require": {
@@ -1621,7 +2244,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1658,6 +2281,9 @@
"polyfill",
"portable"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1672,7 +2298,7 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-02-19T12:13:01+00:00"
},
{
"name": "theseer/tokenizer",
@@ -1712,6 +2338,10 @@
}
],
"description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+ "support": {
+ "issues": "https://github.com/theseer/tokenizer/issues",
+ "source": "https://github.com/theseer/tokenizer/tree/master"
+ },
"funding": [
{
"url": "https://github.com/theseer",
@@ -1722,30 +2352,35 @@
},
{
"name": "webmozart/assert",
- "version": "1.9.1",
+ "version": "1.10.0",
"source": {
"type": "git",
- "url": "https://github.com/webmozart/assert.git",
- "reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389"
+ "url": "https://github.com/webmozarts/assert.git",
+ "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/webmozart/assert/zipball/bafc69caeb4d49c39fd0779086c03a3738cbb389",
- "reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389",
+ "url": "https://api.github.com/repos/webmozarts/assert/zipball/6964c76c7804814a842473e0c8fd15bab0f18e25",
+ "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25",
"shasum": ""
},
"require": {
- "php": "^5.3.3 || ^7.0 || ^8.0",
+ "php": "^7.2 || ^8.0",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"phpstan/phpstan": "<0.12.20",
- "vimeo/psalm": "<3.9.1"
+ "vimeo/psalm": "<4.6.1 || 4.6.2"
},
"require-dev": {
- "phpunit/phpunit": "^4.8.36 || ^7.5.13"
+ "phpunit/phpunit": "^8.5.13"
},
"type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.10-dev"
+ }
+ },
"autoload": {
"psr-4": {
"Webmozart\\Assert\\": "src/"
@@ -1767,30 +2402,33 @@
"check",
"validate"
],
- "time": "2020-07-08T17:02:28+00:00"
+ "support": {
+ "issues": "https://github.com/webmozarts/assert/issues",
+ "source": "https://github.com/webmozarts/assert/tree/1.10.0"
+ },
+ "time": "2021-03-09T10:59:23+00:00"
},
{
"name": "webmozart/glob",
- "version": "4.1.0",
+ "version": "4.3.0",
"source": {
"type": "git",
- "url": "https://github.com/webmozart/glob.git",
- "reference": "3cbf63d4973cf9d780b93d2da8eec7e4a9e63bbe"
+ "url": "https://github.com/webmozarts/glob.git",
+ "reference": "06358fafde0f32edb4513f4fd88fe113a40c90ee"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/webmozart/glob/zipball/3cbf63d4973cf9d780b93d2da8eec7e4a9e63bbe",
- "reference": "3cbf63d4973cf9d780b93d2da8eec7e4a9e63bbe",
+ "url": "https://api.github.com/repos/webmozarts/glob/zipball/06358fafde0f32edb4513f4fd88fe113a40c90ee",
+ "reference": "06358fafde0f32edb4513f4fd88fe113a40c90ee",
"shasum": ""
},
"require": {
- "php": "^5.3.3|^7.0",
+ "php": "^7.3 || ^8.0.0",
"webmozart/path-util": "^2.2"
},
"require-dev": {
- "phpunit/phpunit": "^4.6",
- "sebastian/version": "^1.0.1",
- "symfony/filesystem": "^2.5"
+ "phpunit/phpunit": "^8.0",
+ "symfony/filesystem": "^5.1"
},
"type": "library",
"extra": {
@@ -1814,7 +2452,11 @@
}
],
"description": "A PHP implementation of Ant's glob.",
- "time": "2015-12-29T11:14:33+00:00"
+ "support": {
+ "issues": "https://github.com/webmozarts/glob/issues",
+ "source": "https://github.com/webmozarts/glob/tree/4.3.0"
+ },
+ "time": "2021-01-21T06:17:15+00:00"
},
{
"name": "webmozart/path-util",
@@ -1860,6 +2502,10 @@
}
],
"description": "A robust cross-platform utility for normalizing, comparing and modifying file paths.",
+ "support": {
+ "issues": "https://github.com/webmozart/path-util/issues",
+ "source": "https://github.com/webmozart/path-util/tree/2.3.0"
+ },
"time": "2015-12-17T08:42:14+00:00"
}
],
@@ -1870,5 +2516,5 @@
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
- "plugin-api-version": "1.1.0"
+ "plugin-api-version": "2.0.0"
}
diff --git a/vendor-bin/robo/composer.json b/vendor-bin/robo/composer.json
index 54a362d2..b3180b72 100644
--- a/vendor-bin/robo/composer.json
+++ b/vendor-bin/robo/composer.json
@@ -1,7 +1,6 @@
{
"require-dev": {
- "consolidation/robo": "^1.1",
- "pear/archive_tar": "^1.4",
- "symfony/process": "^3.0"
+ "consolidation/robo": "^3.0",
+ "pear/archive_tar": "^1.4"
}
}
diff --git a/vendor-bin/robo/composer.lock b/vendor-bin/robo/composer.lock
index 5fbe6804..81cfcfec 100644
--- a/vendor-bin/robo/composer.lock
+++ b/vendor-bin/robo/composer.lock
@@ -4,51 +4,38 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "a4e3a5e9da2703b2a1acc144e941a4ac",
+ "content-hash": "ee0b828426eaa5ff905ef60d9a4b9aca",
"packages": [],
"packages-dev": [
{
"name": "consolidation/annotated-command",
- "version": "4.2.3",
+ "version": "4.2.4",
"source": {
"type": "git",
"url": "https://github.com/consolidation/annotated-command.git",
- "reference": "4b596872f24c39d9c04d7b3adb6bc51baa1f2fd5"
+ "reference": "ec297e05cb86557671c2d6cbb1bebba6c7ae2c60"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/consolidation/annotated-command/zipball/4b596872f24c39d9c04d7b3adb6bc51baa1f2fd5",
- "reference": "4b596872f24c39d9c04d7b3adb6bc51baa1f2fd5",
+ "url": "https://api.github.com/repos/consolidation/annotated-command/zipball/ec297e05cb86557671c2d6cbb1bebba6c7ae2c60",
+ "reference": "ec297e05cb86557671c2d6cbb1bebba6c7ae2c60",
"shasum": ""
},
"require": {
"consolidation/output-formatters": "^4.1.1",
"php": ">=7.1.3",
"psr/log": "^1|^2",
- "symfony/console": "^4.4.8|^5",
+ "symfony/console": "^4.4.8|~5.1.0",
"symfony/event-dispatcher": "^4.4.8|^5",
"symfony/finder": "^4.4.8|^5"
},
"require-dev": {
- "g1a/composer-test-scenarios": "^3",
- "php-coveralls/php-coveralls": "^2.2",
- "phpunit/phpunit": "^6",
- "squizlabs/php_codesniffer": "^3"
+ "phpunit/phpunit": ">=7.5.20",
+ "squizlabs/php_codesniffer": "^3",
+ "yoast/phpunit-polyfills": "^0.2.0"
},
"type": "library",
"extra": {
- "scenarios": {
- "symfony4": {
- "require": {
- "symfony/console": "^4.0"
- },
- "config": {
- "platform": {
- "php": "7.1.3"
- }
- }
- }
- },
"branch-alias": {
"dev-main": "4.x-dev"
}
@@ -69,69 +56,48 @@
}
],
"description": "Initialize Symfony Console commands from annotated command class methods.",
- "time": "2020-10-03T14:28:42+00:00"
+ "support": {
+ "issues": "https://github.com/consolidation/annotated-command/issues",
+ "source": "https://github.com/consolidation/annotated-command/tree/4.2.4"
+ },
+ "time": "2020-12-10T16:56:39+00:00"
},
{
"name": "consolidation/config",
- "version": "1.2.1",
+ "version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/consolidation/config.git",
- "reference": "cac1279bae7efb5c7fb2ca4c3ba4b8eb741a96c1"
+ "reference": "9a2c2a7b2aea1b3525984a4378743a8b74c14e1c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/consolidation/config/zipball/cac1279bae7efb5c7fb2ca4c3ba4b8eb741a96c1",
- "reference": "cac1279bae7efb5c7fb2ca4c3ba4b8eb741a96c1",
+ "url": "https://api.github.com/repos/consolidation/config/zipball/9a2c2a7b2aea1b3525984a4378743a8b74c14e1c",
+ "reference": "9a2c2a7b2aea1b3525984a4378743a8b74c14e1c",
"shasum": ""
},
"require": {
"dflydev/dot-access-data": "^1.1.0",
"grasmash/expander": "^1",
- "php": ">=5.4.0"
+ "php": ">=7.1.3",
+ "psr/log": "^1.1",
+ "symfony/event-dispatcher": "^4||^5"
},
"require-dev": {
- "g1a/composer-test-scenarios": "^3",
- "php-coveralls/php-coveralls": "^1",
- "phpunit/phpunit": "^5",
- "squizlabs/php_codesniffer": "2.*",
- "symfony/console": "^2.5|^3|^4",
- "symfony/yaml": "^2.8.11|^3|^4"
+ "phpunit/phpunit": ">=7.5.20",
+ "squizlabs/php_codesniffer": "^3",
+ "symfony/console": "^4||^5",
+ "symfony/yaml": "^4||^5",
+ "yoast/phpunit-polyfills": "^0.2.0"
},
"suggest": {
+ "symfony/event-dispatcher": "Required to inject configuration into Command options",
"symfony/yaml": "Required to use Consolidation\\Config\\Loader\\YamlConfigLoader"
},
"type": "library",
"extra": {
- "scenarios": {
- "symfony4": {
- "require-dev": {
- "symfony/console": "^4.0"
- },
- "config": {
- "platform": {
- "php": "7.1.3"
- }
- }
- },
- "symfony2": {
- "require-dev": {
- "symfony/console": "^2.8",
- "symfony/event-dispatcher": "^2.8",
- "phpunit/phpunit": "^4.8.36"
- },
- "remove": [
- "php-coveralls/php-coveralls"
- ],
- "config": {
- "platform": {
- "php": "5.4.8"
- }
- }
- }
- },
"branch-alias": {
- "dev-master": "1.x-dev"
+ "dev-main": "2.x-dev"
}
},
"autoload": {
@@ -150,20 +116,24 @@
}
],
"description": "Provide configuration services for a commandline tool.",
- "time": "2019-03-03T19:37:04+00:00"
+ "support": {
+ "issues": "https://github.com/consolidation/config/issues",
+ "source": "https://github.com/consolidation/config/tree/2.0.1"
+ },
+ "time": "2020-12-06T00:03:30+00:00"
},
{
"name": "consolidation/log",
- "version": "2.0.1",
+ "version": "2.0.2",
"source": {
"type": "git",
"url": "https://github.com/consolidation/log.git",
- "reference": "ba0bf6af1fbd09ed4dc18fc2f27b12ceff487cbf"
+ "reference": "82a2aaaa621a7b976e50a745a8d249d5085ee2b1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/consolidation/log/zipball/ba0bf6af1fbd09ed4dc18fc2f27b12ceff487cbf",
- "reference": "ba0bf6af1fbd09ed4dc18fc2f27b12ceff487cbf",
+ "url": "https://api.github.com/repos/consolidation/log/zipball/82a2aaaa621a7b976e50a745a8d249d5085ee2b1",
+ "reference": "82a2aaaa621a7b976e50a745a8d249d5085ee2b1",
"shasum": ""
},
"require": {
@@ -172,27 +142,14 @@
"symfony/console": "^4|^5"
},
"require-dev": {
- "g1a/composer-test-scenarios": "^3",
- "php-coveralls/php-coveralls": "^2.2",
- "phpunit/phpunit": "^6",
- "squizlabs/php_codesniffer": "^3"
+ "phpunit/phpunit": ">=7.5.20",
+ "squizlabs/php_codesniffer": "^3",
+ "yoast/phpunit-polyfills": "^0.2.0"
},
"type": "library",
"extra": {
- "scenarios": {
- "symfony4": {
- "require-dev": {
- "symfony/console": "^4"
- },
- "config": {
- "platform": {
- "php": "7.1.3"
- }
- }
- }
- },
"branch-alias": {
- "dev-master": "2.x-dev"
+ "dev-main": "2.x-dev"
}
},
"autoload": {
@@ -211,20 +168,24 @@
}
],
"description": "Improved Psr-3 / Psr\\Log logger based on Symfony Console components.",
- "time": "2020-05-27T17:06:13+00:00"
+ "support": {
+ "issues": "https://github.com/consolidation/log/issues",
+ "source": "https://github.com/consolidation/log/tree/2.0.2"
+ },
+ "time": "2020-12-10T16:26:23+00:00"
},
{
"name": "consolidation/output-formatters",
- "version": "4.1.1",
+ "version": "4.1.2",
"source": {
"type": "git",
"url": "https://github.com/consolidation/output-formatters.git",
- "reference": "9deeddd6a916d0a756b216a8b40ce1016e17c0b9"
+ "reference": "5821e6ae076bf690058a4de6c94dce97398a69c9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/consolidation/output-formatters/zipball/9deeddd6a916d0a756b216a8b40ce1016e17c0b9",
- "reference": "9deeddd6a916d0a756b216a8b40ce1016e17c0b9",
+ "url": "https://api.github.com/repos/consolidation/output-formatters/zipball/5821e6ae076bf690058a4de6c94dce97398a69c9",
+ "reference": "5821e6ae076bf690058a4de6c94dce97398a69c9",
"shasum": ""
},
"require": {
@@ -234,32 +195,20 @@
"symfony/finder": "^4|^5"
},
"require-dev": {
- "g1a/composer-test-scenarios": "^3",
- "php-coveralls/php-coveralls": "^2.2",
- "phpunit/phpunit": "^6",
+ "php-coveralls/php-coveralls": "^2.4.2",
+ "phpunit/phpunit": ">=7",
"squizlabs/php_codesniffer": "^3",
"symfony/var-dumper": "^4",
- "symfony/yaml": "^4"
+ "symfony/yaml": "^4",
+ "yoast/phpunit-polyfills": "^0.2.0"
},
"suggest": {
"symfony/var-dumper": "For using the var_dump formatter"
},
"type": "library",
"extra": {
- "scenarios": {
- "symfony4": {
- "require": {
- "symfony/console": "^4.0"
- },
- "config": {
- "platform": {
- "php": "7.1.3"
- }
- }
- }
- },
"branch-alias": {
- "dev-master": "4.x-dev"
+ "dev-main": "4.x-dev"
}
},
"autoload": {
@@ -278,54 +227,57 @@
}
],
"description": "Format text by applying transformations provided by plug-in formatters.",
- "time": "2020-05-27T20:51:17+00:00"
+ "support": {
+ "issues": "https://github.com/consolidation/output-formatters/issues",
+ "source": "https://github.com/consolidation/output-formatters/tree/4.1.2"
+ },
+ "time": "2020-12-12T19:04:59+00:00"
},
{
"name": "consolidation/robo",
- "version": "1.4.13",
+ "version": "3.0.3",
"source": {
"type": "git",
"url": "https://github.com/consolidation/Robo.git",
- "reference": "fd28dcca1b935950ece26e63541fbdeeb09f7343"
+ "reference": "734620ad3f9bb457fda1a52338b42439115cf941"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/consolidation/Robo/zipball/fd28dcca1b935950ece26e63541fbdeeb09f7343",
- "reference": "fd28dcca1b935950ece26e63541fbdeeb09f7343",
+ "url": "https://api.github.com/repos/consolidation/Robo/zipball/734620ad3f9bb457fda1a52338b42439115cf941",
+ "reference": "734620ad3f9bb457fda1a52338b42439115cf941",
"shasum": ""
},
"require": {
- "consolidation/annotated-command": "^2.12.1|^4.1",
- "consolidation/config": "^1.2.1",
- "consolidation/log": "^1.1.1|^2",
- "consolidation/output-formatters": "^3.5.1|^4.1",
- "consolidation/self-update": "^1.1.5",
- "grasmash/yaml-expander": "^1.4",
- "league/container": "^2.4.1",
- "php": ">=5.5.0",
- "symfony/console": "^2.8|^3|^4",
- "symfony/event-dispatcher": "^2.5|^3|^4",
- "symfony/filesystem": "^2.5|^3|^4",
- "symfony/finder": "^2.5|^3|^4|^5",
- "symfony/process": "^2.5|^3|^4"
+ "consolidation/annotated-command": "^4.2.4",
+ "consolidation/config": "^1.2.1|^2.0.1",
+ "consolidation/log": "^1.1.1|^2.0.2",
+ "consolidation/output-formatters": "^4.1.2",
+ "consolidation/self-update": "^1.2",
+ "league/container": "^3.3.1",
+ "php": ">=7.1.3",
+ "symfony/console": "^4.4.19 || ^5",
+ "symfony/event-dispatcher": "^4.4.19 || ^5",
+ "symfony/filesystem": "^4.4.9 || ^5",
+ "symfony/finder": "^4.4.9 || ^5",
+ "symfony/process": "^4.4.9 || ^5",
+ "symfony/yaml": "^4.4 || ^5"
},
- "replace": {
- "codegyre/robo": "< 1.0"
+ "conflict": {
+ "codegyre/robo": "*"
},
"require-dev": {
- "g1a/composer-test-scenarios": "^3",
"natxet/cssmin": "3.0.4",
"patchwork/jsqueeze": "^2",
"pear/archive_tar": "^1.4.4",
- "php-coveralls/php-coveralls": "^1",
- "phpunit/phpunit": "^5.7.27",
- "squizlabs/php_codesniffer": "^3"
+ "phpunit/phpunit": "^7.5.20 | ^8",
+ "squizlabs/php_codesniffer": "^3",
+ "yoast/phpunit-polyfills": "^0.2.0"
},
"suggest": {
- "henrikbjorn/lurker": "For monitoring filesystem changes in taskWatch",
- "natxet/CssMin": "For minifying CSS files in taskMinify",
+ "natxet/cssmin": "For minifying CSS files in taskMinify",
"patchwork/jsqueeze": "For minifying JS files in taskMinify",
- "pear/archive_tar": "Allows tar archives to be created and extracted in taskPack and taskExtract, respectively."
+ "pear/archive_tar": "Allows tar archives to be created and extracted in taskPack and taskExtract, respectively.",
+ "totten/lurkerlite": "For monitoring filesystem changes in taskWatch"
},
"bin": [
"robo"
@@ -333,48 +285,29 @@
"type": "library",
"extra": {
"scenarios": {
- "finder5": {
- "require": {
- "symfony/finder": "^5"
- },
- "config": {
- "platform": {
- "php": "7.2.5"
- }
- }
- },
"symfony4": {
"require": {
- "symfony/console": "^4"
+ "symfony/console": "^4.4.11",
+ "symfony/event-dispatcher": "^4.4.11",
+ "symfony/filesystem": "^4.4.11",
+ "symfony/finder": "^4.4.11",
+ "symfony/process": "^4.4.11",
+ "phpunit/phpunit": "^6",
+ "nikic/php-parser": "^2"
},
+ "remove": [
+ "codeception/phpunit-wrapper"
+ ],
"config": {
"platform": {
"php": "7.1.3"
}
}
- },
- "symfony2": {
- "require": {
- "symfony/console": "^2.8"
- },
- "require-dev": {
- "phpunit/phpunit": "^4.8.36"
- },
- "remove": [
- "php-coveralls/php-coveralls"
- ],
- "config": {
- "platform": {
- "php": "5.5.9"
- }
- },
- "scenario-options": {
- "create-lockfile": "false"
- }
}
},
"branch-alias": {
- "dev-master": "1.x-dev"
+ "dev-master": "2.x-dev",
+ "dev-main": "2.x-dev"
}
},
"autoload": {
@@ -393,7 +326,11 @@
}
],
"description": "Modern task runner",
- "time": "2020-10-11T04:51:34+00:00"
+ "support": {
+ "issues": "https://github.com/consolidation/Robo/issues",
+ "source": "https://github.com/consolidation/Robo/tree/3.0.3"
+ },
+ "time": "2021-02-21T19:19:43+00:00"
},
{
"name": "consolidation/self-update",
@@ -443,40 +380,12 @@
}
],
"description": "Provides a self:update command for Symfony Console applications.",
+ "support": {
+ "issues": "https://github.com/consolidation/self-update/issues",
+ "source": "https://github.com/consolidation/self-update/tree/1.2.0"
+ },
"time": "2020-04-13T02:49:20+00:00"
},
- {
- "name": "container-interop/container-interop",
- "version": "1.2.0",
- "source": {
- "type": "git",
- "url": "https://github.com/container-interop/container-interop.git",
- "reference": "79cbf1341c22ec75643d841642dd5d6acd83bdb8"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/container-interop/container-interop/zipball/79cbf1341c22ec75643d841642dd5d6acd83bdb8",
- "reference": "79cbf1341c22ec75643d841642dd5d6acd83bdb8",
- "shasum": ""
- },
- "require": {
- "psr/container": "^1.0"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Interop\\Container\\": "src/Interop/Container/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "Promoting the interoperability of container objects (DIC, SL, etc.)",
- "homepage": "https://github.com/container-interop/container-interop",
- "abandoned": "psr/container",
- "time": "2017-02-14T19:40:03+00:00"
- },
{
"name": "dflydev/dot-access-data",
"version": "v1.1.0",
@@ -534,6 +443,10 @@
"dot",
"notation"
],
+ "support": {
+ "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues",
+ "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/master"
+ },
"time": "2017-01-20T21:14:22+00:00"
},
{
@@ -581,87 +494,47 @@
}
],
"description": "Expands internal property references in PHP arrays file.",
+ "support": {
+ "issues": "https://github.com/grasmash/expander/issues",
+ "source": "https://github.com/grasmash/expander/tree/master"
+ },
"time": "2017-12-21T22:14:55+00:00"
},
- {
- "name": "grasmash/yaml-expander",
- "version": "1.4.0",
- "source": {
- "type": "git",
- "url": "https://github.com/grasmash/yaml-expander.git",
- "reference": "3f0f6001ae707a24f4d9733958d77d92bf9693b1"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/grasmash/yaml-expander/zipball/3f0f6001ae707a24f4d9733958d77d92bf9693b1",
- "reference": "3f0f6001ae707a24f4d9733958d77d92bf9693b1",
- "shasum": ""
- },
- "require": {
- "dflydev/dot-access-data": "^1.1.0",
- "php": ">=5.4",
- "symfony/yaml": "^2.8.11|^3|^4"
- },
- "require-dev": {
- "greg-1-anderson/composer-test-scenarios": "^1",
- "phpunit/phpunit": "^4.8|^5.5.4",
- "satooshi/php-coveralls": "^1.0.2|dev-master",
- "squizlabs/php_codesniffer": "^2.7"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Grasmash\\YamlExpander\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Matthew Grasmick"
- }
- ],
- "description": "Expands internal property references in a yaml file.",
- "time": "2017-12-16T16:06:03+00:00"
- },
{
"name": "league/container",
- "version": "2.4.1",
+ "version": "3.3.5",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/container.git",
- "reference": "43f35abd03a12977a60ffd7095efd6a7808488c0"
+ "reference": "048ab87810f508dbedbcb7ae941b606eb8ee353b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/container/zipball/43f35abd03a12977a60ffd7095efd6a7808488c0",
- "reference": "43f35abd03a12977a60ffd7095efd6a7808488c0",
+ "url": "https://api.github.com/repos/thephpleague/container/zipball/048ab87810f508dbedbcb7ae941b606eb8ee353b",
+ "reference": "048ab87810f508dbedbcb7ae941b606eb8ee353b",
"shasum": ""
},
"require": {
- "container-interop/container-interop": "^1.2",
- "php": "^5.4.0 || ^7.0"
+ "php": "^7.0 || ^8.0",
+ "psr/container": "^1.0.0 || ^2.0.0"
},
"provide": {
- "container-interop/container-interop-implementation": "^1.2",
"psr/container-implementation": "^1.0"
},
"replace": {
"orno/di": "~2.0"
},
"require-dev": {
- "phpunit/phpunit": "4.*"
+ "phpunit/phpunit": "^6.0",
+ "roave/security-advisories": "dev-master",
+ "scrutinizer/ocular": "^1.8",
+ "squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
"branch-alias": {
+ "dev-master": "3.x-dev",
+ "dev-3.x": "3.x-dev",
"dev-2.x": "2.x-dev",
"dev-1.x": "1.x-dev"
}
@@ -694,20 +567,30 @@
"provider",
"service"
],
- "time": "2017-05-10T09:20:27+00:00"
+ "support": {
+ "issues": "https://github.com/thephpleague/container/issues",
+ "source": "https://github.com/thephpleague/container/tree/3.3.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/philipobenito",
+ "type": "github"
+ }
+ ],
+ "time": "2021-03-16T09:42:56+00:00"
},
{
"name": "pear/archive_tar",
- "version": "1.4.10",
+ "version": "1.4.13",
"source": {
"type": "git",
"url": "https://github.com/pear/Archive_Tar.git",
- "reference": "bbb4f10f71a1da2715ec6d9a683f4f23c507a49b"
+ "reference": "2b87b41178cc6d4ad3cba678a46a1cae49786011"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/bbb4f10f71a1da2715ec6d9a683f4f23c507a49b",
- "reference": "bbb4f10f71a1da2715ec6d9a683f4f23c507a49b",
+ "url": "https://api.github.com/repos/pear/Archive_Tar/zipball/2b87b41178cc6d4ad3cba678a46a1cae49786011",
+ "reference": "2b87b41178cc6d4ad3cba678a46a1cae49786011",
"shasum": ""
},
"require": {
@@ -760,7 +643,21 @@
"archive",
"tar"
],
- "time": "2020-09-15T14:13:23+00:00"
+ "support": {
+ "issues": "http://pear.php.net/bugs/search.php?cmd=display&package_name[]=Archive_Tar",
+ "source": "https://github.com/pear/Archive_Tar"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/mrook",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/michielrook",
+ "type": "patreon"
+ }
+ ],
+ "time": "2021-02-16T10:50:50+00:00"
},
{
"name": "pear/console_getopt",
@@ -807,6 +704,10 @@
}
],
"description": "More info available on: http://pear.php.net/package/Console_Getopt",
+ "support": {
+ "issues": "http://pear.php.net/bugs/search.php?cmd=display&package_name[]=Console_Getopt",
+ "source": "https://github.com/pear/Console_Getopt"
+ },
"time": "2019-11-20T18:27:48+00:00"
},
{
@@ -851,27 +752,31 @@
}
],
"description": "Minimal set of PEAR core files to be used as composer dependency",
+ "support": {
+ "issues": "http://pear.php.net/bugs/search.php?cmd=display&package_name[]=PEAR",
+ "source": "https://github.com/pear/pear-core-minimal"
+ },
"time": "2019-11-19T19:00:24+00:00"
},
{
"name": "pear/pear_exception",
- "version": "v1.0.1",
+ "version": "v1.0.2",
"source": {
"type": "git",
"url": "https://github.com/pear/PEAR_Exception.git",
- "reference": "dbb42a5a0e45f3adcf99babfb2a1ba77b8ac36a7"
+ "reference": "b14fbe2ddb0b9f94f5b24cf08783d599f776fff0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/pear/PEAR_Exception/zipball/dbb42a5a0e45f3adcf99babfb2a1ba77b8ac36a7",
- "reference": "dbb42a5a0e45f3adcf99babfb2a1ba77b8ac36a7",
+ "url": "https://api.github.com/repos/pear/PEAR_Exception/zipball/b14fbe2ddb0b9f94f5b24cf08783d599f776fff0",
+ "reference": "b14fbe2ddb0b9f94f5b24cf08783d599f776fff0",
"shasum": ""
},
"require": {
- "php": ">=4.4.0"
+ "php": ">=5.2.0"
},
"require-dev": {
- "phpunit/phpunit": "*"
+ "phpunit/phpunit": "<9"
},
"type": "class",
"extra": {
@@ -906,31 +811,30 @@
"keywords": [
"exception"
],
- "time": "2019-12-10T10:24:42+00:00"
+ "support": {
+ "issues": "http://pear.php.net/bugs/search.php?cmd=display&package_name[]=PEAR_Exception",
+ "source": "https://github.com/pear/PEAR_Exception"
+ },
+ "time": "2021-03-21T15:43:46+00:00"
},
{
"name": "psr/container",
- "version": "1.0.0",
+ "version": "1.1.1",
"source": {
"type": "git",
"url": "https://github.com/php-fig/container.git",
- "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f"
+ "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-fig/container/zipball/b7ce3b176482dbbc1245ebf52b181af44c2cf55f",
- "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f",
+ "url": "https://api.github.com/repos/php-fig/container/zipball/8622567409010282b7aeebe4bb841fe98b58dcaf",
+ "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf",
"shasum": ""
},
"require": {
- "php": ">=5.3.0"
+ "php": ">=7.2.0"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.0.x-dev"
- }
- },
"autoload": {
"psr-4": {
"Psr\\Container\\": "src/"
@@ -943,7 +847,7 @@
"authors": [
{
"name": "PHP-FIG",
- "homepage": "http://www.php-fig.org/"
+ "homepage": "https://www.php-fig.org/"
}
],
"description": "Common Container Interface (PHP FIG PSR-11)",
@@ -955,20 +859,74 @@
"container-interop",
"psr"
],
- "time": "2017-02-14T16:28:37+00:00"
+ "support": {
+ "issues": "https://github.com/php-fig/container/issues",
+ "source": "https://github.com/php-fig/container/tree/1.1.1"
+ },
+ "time": "2021-03-05T17:36:06+00:00"
},
{
- "name": "psr/log",
- "version": "1.1.3",
+ "name": "psr/event-dispatcher",
+ "version": "1.0.0",
"source": {
"type": "git",
- "url": "https://github.com/php-fig/log.git",
- "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc"
+ "url": "https://github.com/php-fig/event-dispatcher.git",
+ "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc",
- "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc",
+ "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
+ "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\EventDispatcher\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Standard interfaces for event handling.",
+ "keywords": [
+ "events",
+ "psr",
+ "psr-14"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/event-dispatcher/issues",
+ "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
+ },
+ "time": "2019-01-08T18:20:26+00:00"
+ },
+ {
+ "name": "psr/log",
+ "version": "1.1.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/log.git",
+ "reference": "d49695b909c3b7628b6289db5479a1c204601f11"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11",
+ "reference": "d49695b909c3b7628b6289db5479a1c204601f11",
"shasum": ""
},
"require": {
@@ -992,7 +950,7 @@
"authors": [
{
"name": "PHP-FIG",
- "homepage": "http://www.php-fig.org/"
+ "homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for logging libraries",
@@ -1002,46 +960,51 @@
"psr",
"psr-3"
],
- "time": "2020-03-23T09:12:05+00:00"
+ "support": {
+ "source": "https://github.com/php-fig/log/tree/1.1.4"
+ },
+ "time": "2021-05-03T11:20:27+00:00"
},
{
"name": "symfony/console",
- "version": "v4.4.15",
+ "version": "v5.1.11",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "90933b39c7b312fc3ceaa1ddeac7eb48cb953124"
+ "reference": "d9a267b621c5082e0a6c659d73633b6fd28a8a08"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/90933b39c7b312fc3ceaa1ddeac7eb48cb953124",
- "reference": "90933b39c7b312fc3ceaa1ddeac7eb48cb953124",
+ "url": "https://api.github.com/repos/symfony/console/zipball/d9a267b621c5082e0a6c659d73633b6fd28a8a08",
+ "reference": "d9a267b621c5082e0a6c659d73633b6fd28a8a08",
"shasum": ""
},
"require": {
- "php": ">=7.1.3",
+ "php": ">=7.2.5",
"symfony/polyfill-mbstring": "~1.0",
"symfony/polyfill-php73": "^1.8",
"symfony/polyfill-php80": "^1.15",
- "symfony/service-contracts": "^1.1|^2"
+ "symfony/service-contracts": "^1.1|^2",
+ "symfony/string": "^5.1"
},
"conflict": {
- "symfony/dependency-injection": "<3.4",
- "symfony/event-dispatcher": "<4.3|>=5",
+ "symfony/dependency-injection": "<4.4",
+ "symfony/dotenv": "<5.1",
+ "symfony/event-dispatcher": "<4.4",
"symfony/lock": "<4.4",
- "symfony/process": "<3.3"
+ "symfony/process": "<4.4"
},
"provide": {
"psr/log-implementation": "1.0"
},
"require-dev": {
"psr/log": "~1.0",
- "symfony/config": "^3.4|^4.0|^5.0",
- "symfony/dependency-injection": "^3.4|^4.0|^5.0",
- "symfony/event-dispatcher": "^4.3",
+ "symfony/config": "^4.4|^5.0",
+ "symfony/dependency-injection": "^4.4|^5.0",
+ "symfony/event-dispatcher": "^4.4|^5.0",
"symfony/lock": "^4.4|^5.0",
- "symfony/process": "^3.4|^4.0|^5.0",
- "symfony/var-dumper": "^4.3|^5.0"
+ "symfony/process": "^4.4|^5.0",
+ "symfony/var-dumper": "^4.4|^5.0"
},
"suggest": {
"psr/log": "For using the console logger",
@@ -1050,11 +1013,6 @@
"symfony/process": ""
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "4.4-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Console\\": ""
@@ -1077,8 +1035,11 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Console Component",
+ "description": "Eases the creation of beautiful and testable command line interfaces",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/console/tree/v5.1.11"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1093,53 +1054,117 @@
"type": "tidelift"
}
],
- "time": "2020-09-15T07:58:55+00:00"
+ "time": "2021-01-27T10:01:46+00:00"
},
{
- "name": "symfony/event-dispatcher",
- "version": "v4.4.15",
+ "name": "symfony/deprecation-contracts",
+ "version": "v2.4.0",
"source": {
"type": "git",
- "url": "https://github.com/symfony/event-dispatcher.git",
- "reference": "e17bb5e0663dc725f7cdcafc932132735b4725cd"
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "5f38c8804a9e97d23e0c8d63341088cd8a22d627"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/e17bb5e0663dc725f7cdcafc932132735b4725cd",
- "reference": "e17bb5e0663dc725f7cdcafc932132735b4725cd",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/5f38c8804a9e97d23e0c8d63341088cd8a22d627",
+ "reference": "5f38c8804a9e97d23e0c8d63341088cd8a22d627",
"shasum": ""
},
"require": {
- "php": ">=7.1.3",
- "symfony/event-dispatcher-contracts": "^1.1"
+ "php": ">=7.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "2.4-dev"
+ },
+ "thanks": {
+ "name": "symfony/contracts",
+ "url": "https://github.com/symfony/contracts"
+ }
+ },
+ "autoload": {
+ "files": [
+ "function.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "A generic function and convention to trigger deprecation notices",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v2.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2021-03-23T23:28:01+00:00"
+ },
+ {
+ "name": "symfony/event-dispatcher",
+ "version": "v5.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/event-dispatcher.git",
+ "reference": "67a5f354afa8e2f231081b3fa11a5912f933c3ce"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/67a5f354afa8e2f231081b3fa11a5912f933c3ce",
+ "reference": "67a5f354afa8e2f231081b3fa11a5912f933c3ce",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.5",
+ "symfony/deprecation-contracts": "^2.1",
+ "symfony/event-dispatcher-contracts": "^2",
+ "symfony/polyfill-php80": "^1.15"
},
"conflict": {
- "symfony/dependency-injection": "<3.4"
+ "symfony/dependency-injection": "<4.4"
},
"provide": {
"psr/event-dispatcher-implementation": "1.0",
- "symfony/event-dispatcher-implementation": "1.1"
+ "symfony/event-dispatcher-implementation": "2.0"
},
"require-dev": {
"psr/log": "~1.0",
- "symfony/config": "^3.4|^4.0|^5.0",
- "symfony/dependency-injection": "^3.4|^4.0|^5.0",
- "symfony/error-handler": "~3.4|~4.4",
- "symfony/expression-language": "^3.4|^4.0|^5.0",
- "symfony/http-foundation": "^3.4|^4.0|^5.0",
+ "symfony/config": "^4.4|^5.0",
+ "symfony/dependency-injection": "^4.4|^5.0",
+ "symfony/error-handler": "^4.4|^5.0",
+ "symfony/expression-language": "^4.4|^5.0",
+ "symfony/http-foundation": "^4.4|^5.0",
"symfony/service-contracts": "^1.1|^2",
- "symfony/stopwatch": "^3.4|^4.0|^5.0"
+ "symfony/stopwatch": "^4.4|^5.0"
},
"suggest": {
"symfony/dependency-injection": "",
"symfony/http-kernel": ""
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "4.4-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\EventDispatcher\\": ""
@@ -1162,8 +1187,11 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony EventDispatcher Component",
+ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/event-dispatcher/tree/v5.3.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1178,33 +1206,33 @@
"type": "tidelift"
}
],
- "time": "2020-09-18T14:07:46+00:00"
+ "time": "2021-05-26T17:43:10+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
- "version": "v1.1.9",
+ "version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher-contracts.git",
- "reference": "84e23fdcd2517bf37aecbd16967e83f0caee25a7"
+ "reference": "69fee1ad2332a7cbab3aca13591953da9cdb7a11"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/84e23fdcd2517bf37aecbd16967e83f0caee25a7",
- "reference": "84e23fdcd2517bf37aecbd16967e83f0caee25a7",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/69fee1ad2332a7cbab3aca13591953da9cdb7a11",
+ "reference": "69fee1ad2332a7cbab3aca13591953da9cdb7a11",
"shasum": ""
},
"require": {
- "php": ">=7.1.3"
+ "php": ">=7.2.5",
+ "psr/event-dispatcher": "^1"
},
"suggest": {
- "psr/event-dispatcher": "",
"symfony/event-dispatcher-implementation": ""
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.1-dev"
+ "dev-main": "2.4-dev"
},
"thanks": {
"name": "symfony/contracts",
@@ -1240,6 +1268,9 @@
"interoperability",
"standards"
],
+ "support": {
+ "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v2.4.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1254,32 +1285,27 @@
"type": "tidelift"
}
],
- "time": "2020-07-06T13:19:58+00:00"
+ "time": "2021-03-23T23:28:01+00:00"
},
{
"name": "symfony/filesystem",
- "version": "v4.4.15",
+ "version": "v5.3.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
- "reference": "ebc51494739d3b081ea543ed7c462fa73a4f74db"
+ "reference": "19b71c8f313b411172dd5f470fd61f24466d79a9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/ebc51494739d3b081ea543ed7c462fa73a4f74db",
- "reference": "ebc51494739d3b081ea543ed7c462fa73a4f74db",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/19b71c8f313b411172dd5f470fd61f24466d79a9",
+ "reference": "19b71c8f313b411172dd5f470fd61f24466d79a9",
"shasum": ""
},
"require": {
- "php": ">=7.1.3",
+ "php": ">=7.2.5",
"symfony/polyfill-ctype": "~1.8"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "4.4-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Filesystem\\": ""
@@ -1302,8 +1328,11 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Filesystem Component",
+ "description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/filesystem/tree/v5.3.3"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1318,31 +1347,26 @@
"type": "tidelift"
}
],
- "time": "2020-09-27T13:54:16+00:00"
+ "time": "2021-06-30T07:27:52+00:00"
},
{
"name": "symfony/finder",
- "version": "v5.1.7",
+ "version": "v5.3.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "2c3ba7ad6884e6c4451ce2340e2dc23f6fa3e0d8"
+ "reference": "0ae3f047bed4edff6fd35b26a9a6bfdc92c953c6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/2c3ba7ad6884e6c4451ce2340e2dc23f6fa3e0d8",
- "reference": "2c3ba7ad6884e6c4451ce2340e2dc23f6fa3e0d8",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/0ae3f047bed4edff6fd35b26a9a6bfdc92c953c6",
+ "reference": "0ae3f047bed4edff6fd35b26a9a6bfdc92c953c6",
"shasum": ""
},
"require": {
"php": ">=7.2.5"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.1-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Finder\\": ""
@@ -1365,8 +1389,11 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Finder Component",
+ "description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/finder/tree/v5.3.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1381,20 +1408,20 @@
"type": "tidelift"
}
],
- "time": "2020-09-02T16:23:27+00:00"
+ "time": "2021-05-26T12:52:38+00:00"
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
- "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41"
+ "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f4ba089a5b6366e453971d3aad5fe8e897b37f41",
- "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/46cd95797e9df938fdd2b03693b5fca5e64b01ce",
+ "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce",
"shasum": ""
},
"require": {
@@ -1406,7 +1433,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1443,6 +1470,9 @@
"polyfill",
"portable"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1457,20 +1487,185 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-02-19T12:13:01+00:00"
},
{
- "name": "symfony/polyfill-mbstring",
- "version": "v1.20.0",
+ "name": "symfony/polyfill-intl-grapheme",
+ "version": "v1.23.0",
"source": {
"type": "git",
- "url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "39d483bdf39be819deabf04ec872eb0b2410b531"
+ "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
+ "reference": "24b72c6baa32c746a4d0840147c9715e42bb68ab"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/39d483bdf39be819deabf04ec872eb0b2410b531",
- "reference": "39d483bdf39be819deabf04ec872eb0b2410b531",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/24b72c6baa32c746a4d0840147c9715e42bb68ab",
+ "reference": "24b72c6baa32c746a4d0840147c9715e42bb68ab",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.23-dev"
+ },
+ "thanks": {
+ "name": "symfony/polyfill",
+ "url": "https://github.com/symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+ },
+ "files": [
+ "bootstrap.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's grapheme_* functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "grapheme",
+ "intl",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.23.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2021-05-27T09:17:38+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-normalizer",
+ "version": "v1.23.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
+ "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8590a5f561694770bdcd3f9b5c69dde6945028e8",
+ "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.23-dev"
+ },
+ "thanks": {
+ "name": "symfony/polyfill",
+ "url": "https://github.com/symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
+ },
+ "files": [
+ "bootstrap.php"
+ ],
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's Normalizer class and related functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "intl",
+ "normalizer",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.23.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2021-02-19T12:13:01+00:00"
+ },
+ {
+ "name": "symfony/polyfill-mbstring",
+ "version": "v1.23.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-mbstring.git",
+ "reference": "2df51500adbaebdc4c38dea4c89a2e131c45c8a1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/2df51500adbaebdc4c38dea4c89a2e131c45c8a1",
+ "reference": "2df51500adbaebdc4c38dea4c89a2e131c45c8a1",
"shasum": ""
},
"require": {
@@ -1482,7 +1677,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1520,6 +1715,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1534,20 +1732,20 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-05-27T09:27:20+00:00"
},
{
"name": "symfony/polyfill-php73",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php73.git",
- "reference": "8ff431c517be11c78c48a39a66d37431e26a6bed"
+ "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/8ff431c517be11c78c48a39a66d37431e26a6bed",
- "reference": "8ff431c517be11c78c48a39a66d37431e26a6bed",
+ "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/fba8933c384d6476ab14fb7b8526e5287ca7e010",
+ "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010",
"shasum": ""
},
"require": {
@@ -1556,7 +1754,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1596,6 +1794,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php73/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1610,20 +1811,20 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-02-19T12:13:01+00:00"
},
{
"name": "symfony/polyfill-php80",
- "version": "v1.20.0",
+ "version": "v1.23.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
- "reference": "e70aa8b064c5b72d3df2abd5ab1e90464ad009de"
+ "reference": "eca0bf41ed421bed1b57c4958bab16aa86b757d0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/e70aa8b064c5b72d3df2abd5ab1e90464ad009de",
- "reference": "e70aa8b064c5b72d3df2abd5ab1e90464ad009de",
+ "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/eca0bf41ed421bed1b57c4958bab16aa86b757d0",
+ "reference": "eca0bf41ed421bed1b57c4958bab16aa86b757d0",
"shasum": ""
},
"require": {
@@ -1632,7 +1833,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "1.20-dev"
+ "dev-main": "1.23-dev"
},
"thanks": {
"name": "symfony/polyfill",
@@ -1676,6 +1877,9 @@
"portable",
"shim"
],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php80/tree/v1.23.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1690,31 +1894,27 @@
"type": "tidelift"
}
],
- "time": "2020-10-23T14:02:19+00:00"
+ "time": "2021-02-19T12:13:01+00:00"
},
{
"name": "symfony/process",
- "version": "v3.4.45",
+ "version": "v5.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "46a862d0f334e51c1ed831b49cbe12863ffd5475"
+ "reference": "714b47f9196de61a196d86c4bad5f09201b307df"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/46a862d0f334e51c1ed831b49cbe12863ffd5475",
- "reference": "46a862d0f334e51c1ed831b49cbe12863ffd5475",
+ "url": "https://api.github.com/repos/symfony/process/zipball/714b47f9196de61a196d86c4bad5f09201b307df",
+ "reference": "714b47f9196de61a196d86c4bad5f09201b307df",
"shasum": ""
},
"require": {
- "php": "^5.5.9|>=7.0.8"
+ "php": ">=7.2.5",
+ "symfony/polyfill-php80": "^1.15"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "3.4-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Process\\": ""
@@ -1737,8 +1937,11 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Process Component",
+ "description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/process/tree/v5.3.2"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1753,25 +1956,25 @@
"type": "tidelift"
}
],
- "time": "2020-09-02T16:06:40+00:00"
+ "time": "2021-06-12T10:15:01+00:00"
},
{
"name": "symfony/service-contracts",
- "version": "v2.2.0",
+ "version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
- "reference": "d15da7ba4957ffb8f1747218be9e1a121fd298a1"
+ "reference": "f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d15da7ba4957ffb8f1747218be9e1a121fd298a1",
- "reference": "d15da7ba4957ffb8f1747218be9e1a121fd298a1",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb",
+ "reference": "f040a30e04b57fbcc9c6cbcf4dbaa96bd318b9bb",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
- "psr/container": "^1.0"
+ "psr/container": "^1.1"
},
"suggest": {
"symfony/service-implementation": ""
@@ -1779,7 +1982,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.2-dev"
+ "dev-main": "2.4-dev"
},
"thanks": {
"name": "symfony/contracts",
@@ -1815,6 +2018,9 @@
"interoperability",
"standards"
],
+ "support": {
+ "source": "https://github.com/symfony/service-contracts/tree/v2.4.0"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1829,41 +2035,123 @@
"type": "tidelift"
}
],
- "time": "2020-09-07T11:33:47+00:00"
+ "time": "2021-04-01T10:43:52+00:00"
},
{
- "name": "symfony/yaml",
- "version": "v4.4.15",
+ "name": "symfony/string",
+ "version": "v5.3.3",
"source": {
"type": "git",
- "url": "https://github.com/symfony/yaml.git",
- "reference": "c7885964b1eceb70b0981556d0a9b01d2d97c8d1"
+ "url": "https://github.com/symfony/string.git",
+ "reference": "bd53358e3eccec6a670b5f33ab680d8dbe1d4ae1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/yaml/zipball/c7885964b1eceb70b0981556d0a9b01d2d97c8d1",
- "reference": "c7885964b1eceb70b0981556d0a9b01d2d97c8d1",
+ "url": "https://api.github.com/repos/symfony/string/zipball/bd53358e3eccec6a670b5f33ab680d8dbe1d4ae1",
+ "reference": "bd53358e3eccec6a670b5f33ab680d8dbe1d4ae1",
"shasum": ""
},
"require": {
- "php": ">=7.1.3",
+ "php": ">=7.2.5",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-intl-grapheme": "~1.0",
+ "symfony/polyfill-intl-normalizer": "~1.0",
+ "symfony/polyfill-mbstring": "~1.0",
+ "symfony/polyfill-php80": "~1.15"
+ },
+ "require-dev": {
+ "symfony/error-handler": "^4.4|^5.0",
+ "symfony/http-client": "^4.4|^5.0",
+ "symfony/translation-contracts": "^1.1|^2",
+ "symfony/var-exporter": "^4.4|^5.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\String\\": ""
+ },
+ "files": [
+ "Resources/functions.php"
+ ],
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "grapheme",
+ "i18n",
+ "string",
+ "unicode",
+ "utf-8",
+ "utf8"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/string/tree/v5.3.3"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2021-06-27T11:44:38+00:00"
+ },
+ {
+ "name": "symfony/yaml",
+ "version": "v5.3.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/yaml.git",
+ "reference": "485c83a2fb5893e2ff21bf4bfc7fdf48b4967229"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/485c83a2fb5893e2ff21bf4bfc7fdf48b4967229",
+ "reference": "485c83a2fb5893e2ff21bf4bfc7fdf48b4967229",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.5",
+ "symfony/deprecation-contracts": "^2.1",
"symfony/polyfill-ctype": "~1.8"
},
"conflict": {
- "symfony/console": "<3.4"
+ "symfony/console": "<4.4"
},
"require-dev": {
- "symfony/console": "^3.4|^4.0|^5.0"
+ "symfony/console": "^4.4|^5.0"
},
"suggest": {
"symfony/console": "For validating YAML files using the lint command"
},
+ "bin": [
+ "Resources/bin/yaml-lint"
+ ],
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "4.4-dev"
- }
- },
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
@@ -1886,8 +2174,11 @@
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Symfony Yaml Component",
+ "description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/yaml/tree/v5.3.3"
+ },
"funding": [
{
"url": "https://symfony.com/sponsor",
@@ -1902,7 +2193,7 @@
"type": "tidelift"
}
],
- "time": "2020-09-27T03:36:23+00:00"
+ "time": "2021-06-24T08:13:00+00:00"
}
],
"aliases": [],
@@ -1912,5 +2203,5 @@
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
- "plugin-api-version": "1.1.0"
+ "plugin-api-version": "2.0.0"
}
diff --git a/yarn.lock b/yarn.lock
deleted file mode 100644
index 635cd097..00000000
--- a/yarn.lock
+++ /dev/null
@@ -1,1162 +0,0 @@
-# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
-# yarn lockfile v1
-
-
-"@nodelib/fs.scandir@2.1.3":
- version "2.1.3"
- resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz#3a582bdb53804c6ba6d146579c46e52130cf4a3b"
- integrity sha512-eGmwYQn3gxo4r7jdQnkrrN6bY478C3P+a/y72IJukF8LjB6ZHeB3c+Ehacj3sYeSmUXGlnA67/PmbM9CVwL7Dw==
- dependencies:
- "@nodelib/fs.stat" "2.0.3"
- run-parallel "^1.1.9"
-
-"@nodelib/fs.stat@2.0.3", "@nodelib/fs.stat@^2.0.2":
- version "2.0.3"
- resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.3.tgz#34dc5f4cabbc720f4e60f75a747e7ecd6c175bd3"
- integrity sha512-bQBFruR2TAwoevBEd/NWMoAAtNGzTRgdrqnYCc7dhzfoNvqPzLyqlEQnzZ3kVnNrSp25iyxE00/3h2fqGAGArA==
-
-"@nodelib/fs.walk@^1.2.3":
- version "1.2.4"
- resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.4.tgz#011b9202a70a6366e436ca5c065844528ab04976"
- integrity sha512-1V9XOY4rDW0rehzbrcqAmHnz8e7SKvX27gh8Gt2WgB0+pdzdiLV83p72kZPU+jvMbS1qU5mauP2iOvO8rhmurQ==
- dependencies:
- "@nodelib/fs.scandir" "2.1.3"
- fastq "^1.6.0"
-
-ansi-regex@^2.0.0:
- version "2.1.1"
- resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
- integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8=
-
-ansi-regex@^5.0.0:
- version "5.0.0"
- resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75"
- integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==
-
-ansi-styles@^2.2.1:
- version "2.2.1"
- resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
- integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=
-
-ansi-styles@^3.2.1:
- version "3.2.1"
- resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
- integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
- dependencies:
- color-convert "^1.9.0"
-
-ansi-styles@^4.0.0, ansi-styles@^4.1.0:
- version "4.3.0"
- resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
- integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
- dependencies:
- color-convert "^2.0.1"
-
-anymatch@~3.1.1:
- version "3.1.1"
- resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142"
- integrity sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==
- dependencies:
- normalize-path "^3.0.0"
- picomatch "^2.0.4"
-
-argparse@^1.0.7:
- version "1.0.10"
- resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
- integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==
- dependencies:
- sprintf-js "~1.0.2"
-
-array-union@^2.1.0:
- version "2.1.0"
- resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
- integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==
-
-at-least-node@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2"
- integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==
-
-autoprefixer@^9.6.1:
- version "9.8.6"
- resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.8.6.tgz#3b73594ca1bf9266320c5acf1588d74dea74210f"
- integrity sha512-XrvP4VVHdRBCdX1S3WXVD8+RyG9qeb1D5Sn1DeLiG2xfSpzellk5k54xbUERJ3M5DggQxes39UGOTP8CFrEGbg==
- dependencies:
- browserslist "^4.12.0"
- caniuse-lite "^1.0.30001109"
- colorette "^1.2.1"
- normalize-range "^0.1.2"
- num2fraction "^1.2.2"
- postcss "^7.0.32"
- postcss-value-parser "^4.1.0"
-
-balanced-match@0.1.0:
- version "0.1.0"
- resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.1.0.tgz#b504bd05869b39259dd0c5efc35d843176dccc4a"
- integrity sha1-tQS9BYabOSWd0MXvw12EMXbczEo=
-
-balanced-match@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
- integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
-
-binary-extensions@^2.0.0:
- version "2.1.0"
- resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.1.0.tgz#30fa40c9e7fe07dbc895678cd287024dea241dd9"
- integrity sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==
-
-brace-expansion@^1.1.7:
- version "1.1.11"
- resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
- integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
- dependencies:
- balanced-match "^1.0.0"
- concat-map "0.0.1"
-
-braces@^3.0.1, braces@~3.0.2:
- version "3.0.2"
- resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
- integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
- dependencies:
- fill-range "^7.0.1"
-
-browserslist@^4.12.0:
- version "4.14.5"
- resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.14.5.tgz#1c751461a102ddc60e40993639b709be7f2c4015"
- integrity sha512-Z+vsCZIvCBvqLoYkBFTwEYH3v5MCQbsAjp50ERycpOjnPmolg1Gjy4+KaWWpm8QOJt9GHkhdqAl14NpCX73CWA==
- dependencies:
- caniuse-lite "^1.0.30001135"
- electron-to-chromium "^1.3.571"
- escalade "^3.1.0"
- node-releases "^1.1.61"
-
-caller-callsite@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134"
- integrity sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ=
- dependencies:
- callsites "^2.0.0"
-
-caller-path@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-2.0.0.tgz#468f83044e369ab2010fac5f06ceee15bb2cb1f4"
- integrity sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ=
- dependencies:
- caller-callsite "^2.0.0"
-
-callsites@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50"
- integrity sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA=
-
-camelcase@^5.0.0:
- version "5.3.1"
- resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
- integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
-
-caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001135:
- version "1.0.30001151"
- resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001151.tgz#1ddfde5e6fff02aad7940b4edb7d3ac76b0cb00b"
- integrity sha512-Zh3sHqskX6mHNrqUerh+fkf0N72cMxrmflzje/JyVImfpknscMnkeJrlFGJcqTmaa0iszdYptGpWMJCRQDkBVw==
-
-chalk@^1.1.3:
- version "1.1.3"
- resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
- integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=
- dependencies:
- ansi-styles "^2.2.1"
- escape-string-regexp "^1.0.2"
- has-ansi "^2.0.0"
- strip-ansi "^3.0.0"
- supports-color "^2.0.0"
-
-chalk@^2.0.1, chalk@^2.4.1, chalk@^2.4.2:
- version "2.4.2"
- resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
- integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
- dependencies:
- ansi-styles "^3.2.1"
- escape-string-regexp "^1.0.5"
- supports-color "^5.3.0"
-
-chalk@^4.0.0:
- version "4.1.0"
- resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a"
- integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==
- dependencies:
- ansi-styles "^4.1.0"
- supports-color "^7.1.0"
-
-chokidar@^3.3.0:
- version "3.4.3"
- resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.3.tgz#c1df38231448e45ca4ac588e6c79573ba6a57d5b"
- integrity sha512-DtM3g7juCXQxFVSNPNByEC2+NImtBuxQQvWlHunpJIS5Ocr0lG306cC7FCi7cEA0fzmybPUIl4txBIobk1gGOQ==
- dependencies:
- anymatch "~3.1.1"
- braces "~3.0.2"
- glob-parent "~5.1.0"
- is-binary-path "~2.1.0"
- is-glob "~4.0.1"
- normalize-path "~3.0.0"
- readdirp "~3.5.0"
- optionalDependencies:
- fsevents "~2.1.2"
-
-cliui@^6.0.0:
- version "6.0.0"
- resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1"
- integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==
- dependencies:
- string-width "^4.2.0"
- strip-ansi "^6.0.0"
- wrap-ansi "^6.2.0"
-
-clone@^1.0.2:
- version "1.0.4"
- resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
- integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4=
-
-color-convert@^1.3.0, color-convert@^1.9.0:
- version "1.9.3"
- resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
- integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
- dependencies:
- color-name "1.1.3"
-
-color-convert@^2.0.1:
- version "2.0.1"
- resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
- integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
- dependencies:
- color-name "~1.1.4"
-
-color-name@1.1.3:
- version "1.1.3"
- resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
- integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
-
-color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4:
- version "1.1.4"
- resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
- integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
-
-color-string@^0.3.0:
- version "0.3.0"
- resolved "https://registry.yarnpkg.com/color-string/-/color-string-0.3.0.tgz#27d46fb67025c5c2fa25993bfbf579e47841b991"
- integrity sha1-J9RvtnAlxcL6JZk7+/V55HhBuZE=
- dependencies:
- color-name "^1.0.0"
-
-color@^0.11.0:
- version "0.11.4"
- resolved "https://registry.yarnpkg.com/color/-/color-0.11.4.tgz#6d7b5c74fb65e841cd48792ad1ed5e07b904d764"
- integrity sha1-bXtcdPtl6EHNSHkq0e1eB7kE12Q=
- dependencies:
- clone "^1.0.2"
- color-convert "^1.3.0"
- color-string "^0.3.0"
-
-colorette@^1.2.1:
- version "1.2.1"
- resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b"
- integrity sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw==
-
-concat-map@0.0.1:
- version "0.0.1"
- resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
- integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
-
-cosmiconfig@^5.0.0:
- version "5.2.1"
- resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a"
- integrity sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==
- dependencies:
- import-fresh "^2.0.0"
- is-directory "^0.3.1"
- js-yaml "^3.13.1"
- parse-json "^4.0.0"
-
-css-color-function@~1.3.3:
- version "1.3.3"
- resolved "https://registry.yarnpkg.com/css-color-function/-/css-color-function-1.3.3.tgz#8ed24c2c0205073339fafa004bc8c141fccb282e"
- integrity sha1-jtJMLAIFBzM5+voAS8jBQfzLKC4=
- dependencies:
- balanced-match "0.1.0"
- color "^0.11.0"
- debug "^3.1.0"
- rgb "~0.1.0"
-
-css-tree@1.0.0-alpha.39:
- version "1.0.0-alpha.39"
- resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.39.tgz#2bff3ffe1bb3f776cf7eefd91ee5cba77a149eeb"
- integrity sha512-7UvkEYgBAHRG9Nt980lYxjsTrCyHFN53ky3wVsDkiMdVqylqRt+Zc+jm5qw7/qyOvN2dHSYtX0e4MbCCExSvnA==
- dependencies:
- mdn-data "2.0.6"
- source-map "^0.6.1"
-
-cssesc@^3.0.0:
- version "3.0.0"
- resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
- integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
-
-csso@^4.0.2:
- version "4.0.3"
- resolved "https://registry.yarnpkg.com/csso/-/csso-4.0.3.tgz#0d9985dc852c7cc2b2cacfbbe1079014d1a8e903"
- integrity sha512-NL3spysxUkcrOgnpsT4Xdl2aiEiBG6bXswAABQVHcMrfjjBisFOKwLDOmf4wf32aPdcJws1zds2B0Rg+jqMyHQ==
- dependencies:
- css-tree "1.0.0-alpha.39"
-
-debug@^3.1.0:
- version "3.2.6"
- resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
- integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
- dependencies:
- ms "^2.1.1"
-
-decamelize@^1.2.0:
- version "1.2.0"
- resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
- integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
-
-dependency-graph@^0.9.0:
- version "0.9.0"
- resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-0.9.0.tgz#11aed7e203bc8b00f48356d92db27b265c445318"
- integrity sha512-9YLIBURXj4DJMFALxXw9K3Y3rwb5Fk0X5/8ipCzaN84+gKxoHK43tVKRNakCQbiEx07E8Uwhuq21BpUagFhZ8w==
-
-dir-glob@^3.0.1:
- version "3.0.1"
- resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
- integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==
- dependencies:
- path-type "^4.0.0"
-
-electron-to-chromium@^1.3.571:
- version "1.3.583"
- resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.583.tgz#47a9fde74740b1205dba96db2e433132964ba3ee"
- integrity sha512-L9BwLwJohjZW9mQESI79HRzhicPk1DFgM+8hOCfGgGCFEcA3Otpv7QK6SGtYoZvfQfE3wKLh0Hd5ptqUFv3gvQ==
-
-emoji-regex@^8.0.0:
- version "8.0.0"
- resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
- integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
-
-error-ex@^1.3.1:
- version "1.3.2"
- resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
- integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==
- dependencies:
- is-arrayish "^0.2.1"
-
-escalade@^3.1.0:
- version "3.1.1"
- resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
- integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
-
-escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
- version "1.0.5"
- resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
- integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
-
-esprima@^4.0.0:
- version "4.0.1"
- resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
- integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
-
-fast-glob@^3.1.1:
- version "3.2.4"
- resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.4.tgz#d20aefbf99579383e7f3cc66529158c9b98554d3"
- integrity sha512-kr/Oo6PX51265qeuCYsyGypiO5uJFgBS0jksyG7FUeCyQzNwYnzrNIMR1NXfkZXsMYXYLRAHgISHBz8gQcxKHQ==
- dependencies:
- "@nodelib/fs.stat" "^2.0.2"
- "@nodelib/fs.walk" "^1.2.3"
- glob-parent "^5.1.0"
- merge2 "^1.3.0"
- micromatch "^4.0.2"
- picomatch "^2.2.1"
-
-fastq@^1.6.0:
- version "1.8.0"
- resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.8.0.tgz#550e1f9f59bbc65fe185cb6a9b4d95357107f481"
- integrity sha512-SMIZoZdLh/fgofivvIkmknUXyPnvxRE3DhtZ5Me3Mrsk5gyPL42F0xr51TdRXskBxHfMp+07bcYzfsYEsSQA9Q==
- dependencies:
- reusify "^1.0.4"
-
-fill-range@^7.0.1:
- version "7.0.1"
- resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
- integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
- dependencies:
- to-regex-range "^5.0.1"
-
-find-up@^4.1.0:
- version "4.1.0"
- resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
- integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
- dependencies:
- locate-path "^5.0.0"
- path-exists "^4.0.0"
-
-fs-extra@^9.0.0:
- version "9.0.1"
- resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.0.1.tgz#910da0062437ba4c39fedd863f1675ccfefcb9fc"
- integrity sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ==
- dependencies:
- at-least-node "^1.0.0"
- graceful-fs "^4.2.0"
- jsonfile "^6.0.1"
- universalify "^1.0.0"
-
-fsevents@~2.1.2:
- version "2.1.3"
- resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e"
- integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==
-
-function-bind@^1.1.1:
- version "1.1.1"
- resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
- integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
-
-get-caller-file@^2.0.1:
- version "2.0.5"
- resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
- integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
-
-get-stdin@^8.0.0:
- version "8.0.0"
- resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-8.0.0.tgz#cbad6a73feb75f6eeb22ba9e01f89aa28aa97a53"
- integrity sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==
-
-glob-parent@^5.1.0, glob-parent@~5.1.0:
- version "5.1.1"
- resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.1.tgz#b6c1ef417c4e5663ea498f1c45afac6916bbc229"
- integrity sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==
- dependencies:
- is-glob "^4.0.1"
-
-glob@^6.0.4:
- version "6.0.4"
- resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22"
- integrity sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=
- dependencies:
- inflight "^1.0.4"
- inherits "2"
- minimatch "2 || 3"
- once "^1.3.0"
- path-is-absolute "^1.0.0"
-
-globby@^11.0.0:
- version "11.0.1"
- resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.1.tgz#9a2bf107a068f3ffeabc49ad702c79ede8cfd357"
- integrity sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ==
- dependencies:
- array-union "^2.1.0"
- dir-glob "^3.0.1"
- fast-glob "^3.1.1"
- ignore "^5.1.4"
- merge2 "^1.3.0"
- slash "^3.0.0"
-
-graceful-fs@^4.1.6, graceful-fs@^4.2.0:
- version "4.2.4"
- resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb"
- integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==
-
-has-ansi@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
- integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=
- dependencies:
- ansi-regex "^2.0.0"
-
-has-flag@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa"
- integrity sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=
-
-has-flag@^3.0.0:
- version "3.0.0"
- resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
- integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0=
-
-has-flag@^4.0.0:
- version "4.0.0"
- resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
- integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
-
-has@^1.0.3:
- version "1.0.3"
- resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
- integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
- dependencies:
- function-bind "^1.1.1"
-
-ignore@^5.1.4:
- version "5.1.8"
- resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57"
- integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==
-
-import-cwd@^2.0.0:
- version "2.1.0"
- resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9"
- integrity sha1-qmzzbnInYShcs3HsZRn1PiQ1sKk=
- dependencies:
- import-from "^2.1.0"
-
-import-fresh@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546"
- integrity sha1-2BNVwVYS04bGH53dOSLUMEgipUY=
- dependencies:
- caller-path "^2.0.0"
- resolve-from "^3.0.0"
-
-import-from@^2.1.0:
- version "2.1.0"
- resolved "https://registry.yarnpkg.com/import-from/-/import-from-2.1.0.tgz#335db7f2a7affd53aaa471d4b8021dee36b7f3b1"
- integrity sha1-M1238qev/VOqpHHUuAId7ja387E=
- dependencies:
- resolve-from "^3.0.0"
-
-indexes-of@^1.0.1:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607"
- integrity sha1-8w9xbI4r00bHtn0985FVZqfAVgc=
-
-inflight@^1.0.4:
- version "1.0.6"
- resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
- integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
- dependencies:
- once "^1.3.0"
- wrappy "1"
-
-inherits@2:
- version "2.0.4"
- resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
- integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
-
-ip-regex@^4.1.0:
- version "4.2.0"
- resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-4.2.0.tgz#a03f5eb661d9a154e3973a03de8b23dd0ad6892e"
- integrity sha512-n5cDDeTWWRwK1EBoWwRti+8nP4NbytBBY0pldmnIkq6Z55KNFmWofh4rl9dPZpj+U/nVq7gweR3ylrvMt4YZ5A==
-
-is-arrayish@^0.2.1:
- version "0.2.1"
- resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
- integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=
-
-is-binary-path@~2.1.0:
- version "2.1.0"
- resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
- integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
- dependencies:
- binary-extensions "^2.0.0"
-
-is-core-module@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.0.0.tgz#58531b70aed1db7c0e8d4eb1a0a2d1ddd64bd12d"
- integrity sha512-jq1AH6C8MuteOoBPwkxHafmByhL9j5q4OaPGdbuD+ZtQJVzH+i6E3BJDQcBA09k57i2Hh2yQbEG8yObZ0jdlWw==
- dependencies:
- has "^1.0.3"
-
-is-directory@^0.3.1:
- version "0.3.1"
- resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1"
- integrity sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=
-
-is-extglob@^2.1.1:
- version "2.1.1"
- resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
- integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
-
-is-fullwidth-code-point@^3.0.0:
- version "3.0.0"
- resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
- integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
-
-is-glob@^4.0.1, is-glob@~4.0.1:
- version "4.0.1"
- resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc"
- integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
- dependencies:
- is-extglob "^2.1.1"
-
-is-number@^7.0.0:
- version "7.0.0"
- resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
- integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
-
-is-url-superb@^3.0.0:
- version "3.0.0"
- resolved "https://registry.yarnpkg.com/is-url-superb/-/is-url-superb-3.0.0.tgz#b9a1da878a1ac73659047d1e6f4ef22c209d3e25"
- integrity sha512-3faQP+wHCGDQT1qReM5zCPx2mxoal6DzbzquFlCYJLWyy4WPTved33ea2xFbX37z4NoriEwZGIYhFtx8RUB5wQ==
- dependencies:
- url-regex "^5.0.0"
-
-js-base64@^2.1.9:
- version "2.6.4"
- resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.6.4.tgz#f4e686c5de1ea1f867dbcad3d46d969428df98c4"
- integrity sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==
-
-js-yaml@^3.13.1:
- version "3.14.0"
- resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482"
- integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==
- dependencies:
- argparse "^1.0.7"
- esprima "^4.0.0"
-
-json-parse-better-errors@^1.0.1:
- version "1.0.2"
- resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
- integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==
-
-jsonfile@^6.0.1:
- version "6.0.1"
- resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.0.1.tgz#98966cba214378c8c84b82e085907b40bf614179"
- integrity sha512-jR2b5v7d2vIOust+w3wtFKZIfpC2pnRmFAhAC/BuweZFQR8qZzxH1OyrQ10HmdVYiXWkYUqPVsz91cG7EL2FBg==
- dependencies:
- universalify "^1.0.0"
- optionalDependencies:
- graceful-fs "^4.1.6"
-
-locate-path@^5.0.0:
- version "5.0.0"
- resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
- integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
- dependencies:
- p-locate "^4.1.0"
-
-lodash@^4.17.11:
- version "4.17.20"
- resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
- integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
-
-log-symbols@^2.2.0:
- version "2.2.0"
- resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a"
- integrity sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==
- dependencies:
- chalk "^2.0.1"
-
-mdn-data@2.0.6:
- version "2.0.6"
- resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.6.tgz#852dc60fcaa5daa2e8cf6c9189c440ed3e042978"
- integrity sha512-rQvjv71olwNHgiTbfPZFkJtjNMciWgswYeciZhtvWLO8bmX3TnhyA62I6sTWOyZssWHJJjY6/KiWwqQsWWsqOA==
-
-merge2@^1.3.0:
- version "1.4.1"
- resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
- integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
-
-micromatch@^4.0.2:
- version "4.0.2"
- resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259"
- integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==
- dependencies:
- braces "^3.0.1"
- picomatch "^2.0.5"
-
-"minimatch@2 || 3":
- version "3.0.4"
- resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
- integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
- dependencies:
- brace-expansion "^1.1.7"
-
-ms@^2.1.1:
- version "2.1.2"
- resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
- integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
-
-node-releases@^1.1.61:
- version "1.1.64"
- resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.64.tgz#71b4ae988e9b1dd7c1ffce58dd9e561752dfebc5"
- integrity sha512-Iec8O9166/x2HRMJyLLLWkd0sFFLrFNy+Xf+JQfSQsdBJzPcHpNl3JQ9gD4j+aJxmCa25jNsIbM4bmACtSbkSg==
-
-normalize-path@^3.0.0, normalize-path@~3.0.0:
- version "3.0.0"
- resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
- integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
-
-normalize-range@^0.1.2:
- version "0.1.2"
- resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
- integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=
-
-num2fraction@^1.2.2:
- version "1.2.2"
- resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede"
- integrity sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4=
-
-once@^1.3.0:
- version "1.4.0"
- resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
- integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
- dependencies:
- wrappy "1"
-
-p-limit@^2.2.0:
- version "2.3.0"
- resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
- integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
- dependencies:
- p-try "^2.0.0"
-
-p-locate@^4.1.0:
- version "4.1.0"
- resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
- integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==
- dependencies:
- p-limit "^2.2.0"
-
-p-try@^2.0.0:
- version "2.2.0"
- resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
- integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
-
-parse-json@^4.0.0:
- version "4.0.0"
- resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0"
- integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=
- dependencies:
- error-ex "^1.3.1"
- json-parse-better-errors "^1.0.1"
-
-path-exists@^4.0.0:
- version "4.0.0"
- resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
- integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
-
-path-is-absolute@^1.0.0:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
- integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
-
-path-parse@^1.0.6:
- version "1.0.6"
- resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
- integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==
-
-path-type@^4.0.0:
- version "4.0.0"
- resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
- integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
-
-picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1:
- version "2.2.2"
- resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
- integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==
-
-pify@^2.3.0:
- version "2.3.0"
- resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
- integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw=
-
-postcss-cli@^7.1.1:
- version "7.1.2"
- resolved "https://registry.yarnpkg.com/postcss-cli/-/postcss-cli-7.1.2.tgz#ba8d5d918b644bd18e80ad2c698064d4c0da51cd"
- integrity sha512-3mlEmN1v2NVuosMWZM2tP8bgZn7rO5PYxRRrXtdSyL5KipcgBDjJ9ct8/LKxImMCJJi3x5nYhCGFJOkGyEqXBQ==
- dependencies:
- chalk "^4.0.0"
- chokidar "^3.3.0"
- dependency-graph "^0.9.0"
- fs-extra "^9.0.0"
- get-stdin "^8.0.0"
- globby "^11.0.0"
- postcss "^7.0.0"
- postcss-load-config "^2.0.0"
- postcss-reporter "^6.0.0"
- pretty-hrtime "^1.0.3"
- read-cache "^1.0.0"
- yargs "^15.0.2"
-
-postcss-color-function@^4.1.0:
- version "4.1.0"
- resolved "https://registry.yarnpkg.com/postcss-color-function/-/postcss-color-function-4.1.0.tgz#b6f9355e07b12fcc5c34dab957834769b03d8f57"
- integrity sha512-2/fuv6mP5Lt03XbRpVfMdGC8lRP1sykme+H1bR4ARyOmSMB8LPSjcL6EAI1iX6dqUF+jNEvKIVVXhan1w/oFDQ==
- dependencies:
- css-color-function "~1.3.3"
- postcss "^6.0.23"
- postcss-message-helpers "^2.0.0"
- postcss-value-parser "^3.3.1"
-
-postcss-csso@^4.0.0:
- version "4.0.0"
- resolved "https://registry.yarnpkg.com/postcss-csso/-/postcss-csso-4.0.0.tgz#30fef9303ecbeb0424dab1228275416fc7186a50"
- integrity sha512-Yh9Ug0w3+T/LZIh1vGJQY8+hE13yFRHpINoAmgOhvu9lBmG1jyHkAprGHEHlGjWODJzB4DCNBVBb6Cs0QEoglQ==
- dependencies:
- csso "^4.0.2"
-
-postcss-custom-media@^7.0.8:
- version "7.0.8"
- resolved "https://registry.yarnpkg.com/postcss-custom-media/-/postcss-custom-media-7.0.8.tgz#fffd13ffeffad73621be5f387076a28b00294e0c"
- integrity sha512-c9s5iX0Ge15o00HKbuRuTqNndsJUbaXdiNsksnVH8H4gdc+zbLzr/UasOwNG6CTDpLFekVY4672eWdiiWu2GUg==
- dependencies:
- postcss "^7.0.14"
-
-postcss-custom-properties@^9.0.2:
- version "9.2.0"
- resolved "https://registry.yarnpkg.com/postcss-custom-properties/-/postcss-custom-properties-9.2.0.tgz#80bae0d6e0c510245ace7ede95ac527712ea24e7"
- integrity sha512-IFRV7LwapFkNa3MtvFpw+MEhgyUpaVZ62VlR5EM0AbmnGbNhU9qIE8u02vgUbl1gLkHK6sterEavamVPOwdE8g==
- dependencies:
- postcss "^7.0.17"
- postcss-values-parser "^3.0.5"
-
-postcss-discard-comments@^4.0.2:
- version "4.0.2"
- resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-4.0.2.tgz#1fbabd2c246bff6aaad7997b2b0918f4d7af4033"
- integrity sha512-RJutN259iuRf3IW7GZyLM5Sw4GLTOH8FmsXBnv8Ab/Tc2k4SR4qbV4DNbyyY4+Sjo362SyDmW2DQ7lBSChrpkg==
- dependencies:
- postcss "^7.0.0"
-
-postcss-import@^12.0.1:
- version "12.0.1"
- resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-12.0.1.tgz#cf8c7ab0b5ccab5649024536e565f841928b7153"
- integrity sha512-3Gti33dmCjyKBgimqGxL3vcV8w9+bsHwO5UrBawp796+jdardbcFl4RP5w/76BwNL7aGzpKstIfF9I+kdE8pTw==
- dependencies:
- postcss "^7.0.1"
- postcss-value-parser "^3.2.3"
- read-cache "^1.0.0"
- resolve "^1.1.7"
-
-postcss-load-config@^2.0.0:
- version "2.1.2"
- resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-2.1.2.tgz#c5ea504f2c4aef33c7359a34de3573772ad7502a"
- integrity sha512-/rDeGV6vMUo3mwJZmeHfEDvwnTKKqQ0S7OHUi/kJvvtx3aWtyWG2/0ZWnzCt2keEclwN6Tf0DST2v9kITdOKYw==
- dependencies:
- cosmiconfig "^5.0.0"
- import-cwd "^2.0.0"
-
-postcss-media-minmax@^4.0.0:
- version "4.0.0"
- resolved "https://registry.yarnpkg.com/postcss-media-minmax/-/postcss-media-minmax-4.0.0.tgz#b75bb6cbc217c8ac49433e12f22048814a4f5ed5"
- integrity sha512-fo9moya6qyxsjbFAYl97qKO9gyre3qvbMnkOZeZwlsW6XYFsvs2DMGDlchVLfAd8LHPZDxivu/+qW2SMQeTHBw==
- dependencies:
- postcss "^7.0.2"
-
-postcss-message-helpers@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/postcss-message-helpers/-/postcss-message-helpers-2.0.0.tgz#a4f2f4fab6e4fe002f0aed000478cdf52f9ba60e"
- integrity sha1-pPL0+rbk/gAvCu0ABHjN9S+bpg4=
-
-postcss-nested@^4.1.2:
- version "4.2.3"
- resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-4.2.3.tgz#c6f255b0a720549776d220d00c4b70cd244136f6"
- integrity sha512-rOv0W1HquRCamWy2kFl3QazJMMe1ku6rCFoAAH+9AcxdbpDeBr6k968MLWuLjvjMcGEip01ak09hKOEgpK9hvw==
- dependencies:
- postcss "^7.0.32"
- postcss-selector-parser "^6.0.2"
-
-postcss-reporter@^6.0.0:
- version "6.0.1"
- resolved "https://registry.yarnpkg.com/postcss-reporter/-/postcss-reporter-6.0.1.tgz#7c055120060a97c8837b4e48215661aafb74245f"
- integrity sha512-LpmQjfRWyabc+fRygxZjpRxfhRf9u/fdlKf4VHG4TSPbV2XNsuISzYW1KL+1aQzx53CAppa1bKG4APIB/DOXXw==
- dependencies:
- chalk "^2.4.1"
- lodash "^4.17.11"
- log-symbols "^2.2.0"
- postcss "^7.0.7"
-
-postcss-sassy-mixins@^2.1.0:
- version "2.1.0"
- resolved "https://registry.yarnpkg.com/postcss-sassy-mixins/-/postcss-sassy-mixins-2.1.0.tgz#368f200946bfdef6a8b12d68c0f6379b9a222f26"
- integrity sha1-No8gCUa/3vaosS1owPY3m5oiLyY=
- dependencies:
- glob "^6.0.4"
- postcss "^5.0.14"
- postcss-simple-vars "^1.2.0"
-
-postcss-scss@^2.0.0:
- version "2.1.1"
- resolved "https://registry.yarnpkg.com/postcss-scss/-/postcss-scss-2.1.1.tgz#ec3a75fa29a55e016b90bf3269026c53c1d2b383"
- integrity sha512-jQmGnj0hSGLd9RscFw9LyuSVAa5Bl1/KBPqG1NQw9w8ND55nY4ZEsdlVuYJvLPpV+y0nwTV5v/4rHPzZRihQbA==
- dependencies:
- postcss "^7.0.6"
-
-postcss-selector-parser@^6.0.2:
- version "6.0.4"
- resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.4.tgz#56075a1380a04604c38b063ea7767a129af5c2b3"
- integrity sha512-gjMeXBempyInaBqpp8gODmwZ52WaYsVOsfr4L4lDQ7n3ncD6mEyySiDtgzCT+NYC0mmeOLvtsF8iaEf0YT6dBw==
- dependencies:
- cssesc "^3.0.0"
- indexes-of "^1.0.1"
- uniq "^1.0.1"
- util-deprecate "^1.0.2"
-
-postcss-simple-vars@^1.2.0:
- version "1.2.0"
- resolved "https://registry.yarnpkg.com/postcss-simple-vars/-/postcss-simple-vars-1.2.0.tgz#2e6689921144b74114e765353275a3c32143f150"
- integrity sha1-LmaJkhFEt0EU52U1MnWjwyFD8VA=
- dependencies:
- postcss "^5.0.13"
-
-postcss-value-parser@^3.2.3, postcss-value-parser@^3.3.1:
- version "3.3.1"
- resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281"
- integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==
-
-postcss-value-parser@^4.1.0:
- version "4.1.0"
- resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb"
- integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==
-
-postcss-values-parser@^3.0.5:
- version "3.2.1"
- resolved "https://registry.yarnpkg.com/postcss-values-parser/-/postcss-values-parser-3.2.1.tgz#55114607de6631338ba8728d3e9c15785adcc027"
- integrity sha512-SQ7/88VE9LhJh9gc27/hqnSU/aZaREVJcRVccXBmajgP2RkjdJzNyH/a9GCVMI5nsRhT0jC5HpUMwfkz81DVVg==
- dependencies:
- color-name "^1.1.4"
- is-url-superb "^3.0.0"
- postcss "^7.0.5"
- url-regex "^5.0.0"
-
-postcss@^5.0.13, postcss@^5.0.14:
- version "5.2.18"
- resolved "https://registry.yarnpkg.com/postcss/-/postcss-5.2.18.tgz#badfa1497d46244f6390f58b319830d9107853c5"
- integrity sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==
- dependencies:
- chalk "^1.1.3"
- js-base64 "^2.1.9"
- source-map "^0.5.6"
- supports-color "^3.2.3"
-
-postcss@^6.0.23:
- version "6.0.23"
- resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324"
- integrity sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==
- dependencies:
- chalk "^2.4.1"
- source-map "^0.6.1"
- supports-color "^5.4.0"
-
-postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.17, postcss@^7.0.2, postcss@^7.0.32, postcss@^7.0.5, postcss@^7.0.6, postcss@^7.0.7:
- version "7.0.35"
- resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.35.tgz#d2be00b998f7f211d8a276974079f2e92b970e24"
- integrity sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg==
- dependencies:
- chalk "^2.4.2"
- source-map "^0.6.1"
- supports-color "^6.1.0"
-
-pretty-hrtime@^1.0.3:
- version "1.0.3"
- resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
- integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=
-
-read-cache@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774"
- integrity sha1-5mTvMRYRZsl1HNvo28+GtftY93Q=
- dependencies:
- pify "^2.3.0"
-
-readdirp@~3.5.0:
- version "3.5.0"
- resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e"
- integrity sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==
- dependencies:
- picomatch "^2.2.1"
-
-require-directory@^2.1.1:
- version "2.1.1"
- resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
- integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I=
-
-require-main-filename@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
- integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
-
-resolve-from@^3.0.0:
- version "3.0.0"
- resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748"
- integrity sha1-six699nWiBvItuZTM17rywoYh0g=
-
-resolve@^1.1.7:
- version "1.18.1"
- resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.18.1.tgz#018fcb2c5b207d2a6424aee361c5a266da8f4130"
- integrity sha512-lDfCPaMKfOJXjy0dPayzPdF1phampNWr3qFCjAu+rw/qbQmr5jWH5xN2hwh9QKfw9E5v4hwV7A+jrCmL8yjjqA==
- dependencies:
- is-core-module "^2.0.0"
- path-parse "^1.0.6"
-
-reusify@^1.0.4:
- version "1.0.4"
- resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
- integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
-
-rgb@~0.1.0:
- version "0.1.0"
- resolved "https://registry.yarnpkg.com/rgb/-/rgb-0.1.0.tgz#be27b291e8feffeac1bd99729721bfa40fc037b5"
- integrity sha1-vieykej+/+rBvZlylyG/pA/AN7U=
-
-run-parallel@^1.1.9:
- version "1.1.9"
- resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.1.9.tgz#c9dd3a7cf9f4b2c4b6244e173a6ed866e61dd679"
- integrity sha512-DEqnSRTDw/Tc3FXf49zedI638Z9onwUotBMiUFKmrO2sdFKIbXamXGQ3Axd4qgphxKB4kw/qP1w5kTxnfU1B9Q==
-
-set-blocking@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
- integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
-
-slash@^3.0.0:
- version "3.0.0"
- resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
- integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
-
-source-map@^0.5.6:
- version "0.5.7"
- resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
- integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=
-
-source-map@^0.6.1:
- version "0.6.1"
- resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
- integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
-
-sprintf-js@~1.0.2:
- version "1.0.3"
- resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
- integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
-
-string-width@^4.1.0, string-width@^4.2.0:
- version "4.2.0"
- resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5"
- integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==
- dependencies:
- emoji-regex "^8.0.0"
- is-fullwidth-code-point "^3.0.0"
- strip-ansi "^6.0.0"
-
-strip-ansi@^3.0.0:
- version "3.0.1"
- resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
- integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=
- dependencies:
- ansi-regex "^2.0.0"
-
-strip-ansi@^6.0.0:
- version "6.0.0"
- resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532"
- integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==
- dependencies:
- ansi-regex "^5.0.0"
-
-supports-color@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
- integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=
-
-supports-color@^3.2.3:
- version "3.2.3"
- resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.2.3.tgz#65ac0504b3954171d8a64946b2ae3cbb8a5f54f6"
- integrity sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=
- dependencies:
- has-flag "^1.0.0"
-
-supports-color@^5.3.0, supports-color@^5.4.0:
- version "5.5.0"
- resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
- integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
- dependencies:
- has-flag "^3.0.0"
-
-supports-color@^6.1.0:
- version "6.1.0"
- resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3"
- integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==
- dependencies:
- has-flag "^3.0.0"
-
-supports-color@^7.1.0:
- version "7.2.0"
- resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
- integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
- dependencies:
- has-flag "^4.0.0"
-
-tlds@^1.203.0:
- version "1.212.0"
- resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.212.0.tgz#f3f29bd5d10d0fd9a6f171a5f9d57d58b71eccf7"
- integrity sha512-03rYYO1rGhOYpdYB+wlLY2d0xza6hdN/S67ol2ZpaH+CtFedMVAVhj8ft0rwxEkr90zatou8opBv7Xp6X4cK6g==
-
-to-regex-range@^5.0.1:
- version "5.0.1"
- resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
- integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
- dependencies:
- is-number "^7.0.0"
-
-uniq@^1.0.1:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff"
- integrity sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=
-
-universalify@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d"
- integrity sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==
-
-url-regex@^5.0.0:
- version "5.0.0"
- resolved "https://registry.yarnpkg.com/url-regex/-/url-regex-5.0.0.tgz#8f5456ab83d898d18b2f91753a702649b873273a"
- integrity sha512-O08GjTiAFNsSlrUWfqF1jH0H1W3m35ZyadHrGv5krdnmPPoxP27oDTqux/579PtaroiSGm5yma6KT1mHFH6Y/g==
- dependencies:
- ip-regex "^4.1.0"
- tlds "^1.203.0"
-
-util-deprecate@^1.0.2:
- version "1.0.2"
- resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
- integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
-
-which-module@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
- integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=
-
-wrap-ansi@^6.2.0:
- version "6.2.0"
- resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
- integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==
- dependencies:
- ansi-styles "^4.0.0"
- string-width "^4.1.0"
- strip-ansi "^6.0.0"
-
-wrappy@1:
- version "1.0.2"
- resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
- integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
-
-y18n@^4.0.0:
- version "4.0.0"
- resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
- integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==
-
-yargs-parser@^18.1.2:
- version "18.1.3"
- resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
- integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==
- dependencies:
- camelcase "^5.0.0"
- decamelize "^1.2.0"
-
-yargs@^15.0.2:
- version "15.4.1"
- resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
- integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==
- dependencies:
- cliui "^6.0.0"
- decamelize "^1.2.0"
- find-up "^4.1.0"
- get-caller-file "^2.0.1"
- require-directory "^2.1.1"
- require-main-filename "^2.0.0"
- set-blocking "^2.0.0"
- string-width "^4.2.0"
- which-module "^2.0.0"
- y18n "^4.0.0"
- yargs-parser "^18.1.2"