diff --git a/RoboFile.php b/RoboFile.php index e562668..65d0363 100644 --- a/RoboFile.php +++ b/RoboFile.php @@ -76,6 +76,10 @@ class RoboFile extends \Robo\Tasks { } } + protected function isWindows(): bool { + return defined("PHP_WINDOWS_VERSION_MAJOR"); + } + protected function runTests(string $executor, string $set, array $args) : Result { switch ($set) { case "typical": @@ -92,8 +96,9 @@ class RoboFile extends \Robo\Tasks { } $execpath = realpath(self::BASE."vendor-bin/phpunit/vendor/phpunit/phpunit/phpunit"); $confpath = realpath(self::BASE_TEST."phpunit.xml"); + $blackhole = $this->isWindows() ? "nul" : "/dev/null"; $this->taskServer(8000)->host("localhost")->dir(self::BASE_TEST."docroot")->rawArg("-n")->arg(self::BASE_TEST."server.php")->background()->run(); - return $this->taskExec($executor)->arg($execpath)->option("-c", $confpath)->args(array_merge($set, $args))->run(); + return $this->taskExec($executor)->arg($execpath)->option("-c", $confpath)->args(array_merge($set, $args))->rawArg("2>$blackhole")->run(); } /** Packages a given commit of the software into a release tarball diff --git a/lib/Arsse.php b/lib/Arsse.php index 5300df6..0297f2d 100644 --- a/lib/Arsse.php +++ b/lib/Arsse.php @@ -19,10 +19,10 @@ class Arsse { public static $user; public static function load(Conf $conf) { - static::$lang = new Lang(); + static::$lang = static::$lang ?? new Lang; static::$conf = $conf; static::$lang->set($conf->lang); - static::$db = new Database(); - static::$user = new User(); + static::$db = static::$db ?? new Database; + static::$user = static::$user ?? new User; } } diff --git a/lib/CLI.php b/lib/CLI.php index ab85219..b6b587c 100644 --- a/lib/CLI.php +++ b/lib/CLI.php @@ -9,36 +9,27 @@ namespace JKingWeb\Arsse; use Docopt\Response as Opts; class CLI { - protected $args = []; - - protected function usage(): string { - $prog = basename($_SERVER['argv'][0]); - return << - $prog conf save-defaults [] - $prog user [list] - $prog user add [] - $prog user remove - $prog user set-pass [--oldpass=] [] - $prog user auth - $prog --version - $prog --help | -h + arsse.php daemon + 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=] [] + arsse.php user auth + arsse.php --version + arsse.php --help | -h The Arsse command-line interface currently allows you to start the refresh daemon, refresh a specific feed by numeric ID, manage users, or save default configuration to a sample file. USAGE_TEXT; - } - public function __construct(array $argv = null) { - $argv = $argv ?? array_slice($_SERVER['argv'], 1); - $this->args = \Docopt::handle($this->usage(), [ - 'argv' => $argv, - 'help' => true, - 'version' => Arsse::VERSION, - ]); + protected function usage($prog): string { + $prog = basename($prog); + return str_replace("arsse.php", $prog, self::USAGE); } protected function command(array $options, $args): string { @@ -61,19 +52,32 @@ USAGE_TEXT; return true; } - public function dispatch(array $args = null): int { - // act on command line - $args = $args ?? $this->args; + public function dispatch(array $argv = null) { + $argv = $argv ?? $_SERVER['argv']; + $argv0 = array_shift($argv); + $args = \Docopt::handle($this->usage($argv0), [ + 'argv' => $argv, + 'help' => false, + ]); try { - switch ($this->command(["daemon", "feed refresh", "conf save-defaults", "user"], $args)) { + switch ($this->command(["--help", "--version", "daemon", "feed refresh", "conf save-defaults", "user"], $args)) { + case "--help": + echo $this->usage($argv0).\PHP_EOL; + return 0; + case "--version": + echo Arsse::VERSION.\PHP_EOL; + return 0; case "daemon": $this->loadConf(); - return $this->daemon(); + $this->getService()->watch(true); + return 0; case "feed refresh": $this->loadConf(); - return $this->feedRefresh((int) $args['']); + return (int) !Arsse::$db->feedUpdate((int) $args[''], true); case "conf save-defaults": - return $this->confSaveDefaults($args['']); + $file = $args['']; + $file = ($file=="-" ? null : $file) ?? "php://output"; + return (int) !($this->getConf())->exportFile($file, true); case "user": $this->loadConf(); return $this->userManage($args); @@ -84,21 +88,17 @@ USAGE_TEXT; } } - public function daemon(bool $loop = true): int { - (new Service)->watch($loop); - return 0; // FIXME: should return the exception code of thrown exceptions - } - - public function feedRefresh(int $id): int { - return (int) !Arsse::$db->feedUpdate($id); // FIXME: exception error codes should be returned here + /** @codeCoverageIgnore */ + protected function getService(): Service { + return new Service; } - public function confSaveDefaults(string $file = null): int { - $file = ($file=="-" ? null : $file) ?? STDOUT; - return (int) !(new Conf)->exportFile($file, true); + /** @codeCoverageIgnore */ + protected function getConf(): Conf { + return new Conf; } - public function userManage($args): int { + protected function userManage($args): int { switch ($this->command(["add", "remove", "set-pass", "list", "auth"], $args)) { case "add": return $this->userAddOrSetPassword("add", $args[""], $args[""]); @@ -115,9 +115,7 @@ USAGE_TEXT; } protected function userAddOrSetPassword(string $method, string $user, string $password = null, string $oldpass = null): int { - $args = \func_get_args(); - array_shift($args); - $passwd = Arsse::$user->$method(...$args); + $passwd = Arsse::$user->$method(...array_slice(func_get_args(), 1)); if (is_null($password)) { echo $passwd.\PHP_EOL; } diff --git a/tests/cases/CLI/TestCLI.php b/tests/cases/CLI/TestCLI.php new file mode 100644 index 0000000..3dd53a5 --- /dev/null +++ b/tests/cases/CLI/TestCLI.php @@ -0,0 +1,136 @@ +clearData(false); + } + + public function assertConsole(CLI $cli, string $command, int $exitStatus, string $output = "", bool $pattern = false) { + $argv = \Clue\Arguments\split($command); + $output = strlen($output) ? $output.\PHP_EOL : ""; + if ($pattern) { + $this->expectOutputRegex($output); + } else { + $this->expectOutputString($output); + } + $this->assertSame($exitStatus, $cli->dispatch($argv)); + } + + public function assertLoaded(bool $loaded) { + $r = new \ReflectionClass(Arsse::class); + $props = array_keys($r->getStaticProperties()); + foreach ($props as $prop) { + if ($loaded) { + $this->assertNotNull(Arsse::$$prop, "Global $prop object should be loaded"); + } else { + $this->assertNull(Arsse::$$prop, "Global $prop object should not be loaded"); + } + } + } + + public function testPrintVersion() { + $this->assertConsole(new CLI, "arsse.php --version", 0, Arsse::VERSION); + $this->assertLoaded(false); + } + + /** @dataProvider provideHelpText */ + public function testPrintHelp(string $cmd, string $name) { + $this->assertConsole(new CLI, $cmd, 0, str_replace("arsse.php", $name, CLI::USAGE)); + $this->assertLoaded(false); + } + + public function provideHelpText() { + return [ + ["arsse.php --help", "arsse.php"], + ["arsse --help", "arsse"], + ["thearsse --help", "thearsse"], + ]; + } + + public function testStartTheDaemon() { + $srv = Phake::mock(Service::class); + $cli = Phake::partialMock(CLI::class); + Phake::when($srv)->watch->thenReturn(new \DateTimeImmutable); + Phake::when($cli)->getService->thenReturn($srv); + $this->assertConsole($cli, "arsse.php daemon", 0); + $this->assertLoaded(true); + Phake::verify($srv)->watch(true); + Phake::verify($cli)->getService; + } + + /** @dataProvider provideFeedUpdates */ + public function testRefreshAFeed(string $cmd, int $exitStatus, string $output) { + 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/", new \PicoFeed\Client\InvalidUrlException)); + $this->assertConsole(new CLI, $cmd, $exitStatus, $output); + $this->assertLoaded(true); + Phake::verify(Arsse::$db)->feedUpdate; + } + + public function provideFeedUpdates() { + return [ + ["arsse.php feed refresh 1", 0, ""], + ["arsse.php feed refresh 2", 10502, ""], + ]; + } + + /** @dataProvider provideDefaultConfigurationSaves */ + public function testSaveTheDefaultConfiguration(string $cmd, int $exitStatus, string $file) { + $conf = Phake::mock(Conf::class); + $cli = Phake::partialMock(CLI::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($cli)->getConf->thenReturn($conf); + $this->assertConsole($cli, $cmd, $exitStatus); + $this->assertLoaded(false); + Phake::verify($conf)->exportFile($file, true); + } + + public function provideDefaultConfigurationSaves() { + return [ + ["arsse.php conf save-defaults", 0, "php://output"], + ["arsse.php conf save-defaults -", 0, "php://output"], + ["arsse.php conf save-defaults good.conf", 0, "good.conf"], + ["arsse.php conf save-defaults bad.conf", 10304, "bad.conf"], + ]; + } + + /** @dataProvider provideUserList */ + public function testListUsers(string $cmd, array $list, int $exitStatus, string $output) { + Arsse::$user = Phake::mock(User::class); + Phake::when(Arsse::$user)->list()->thenReturn($list); + $this->assertConsole(new CLI, $cmd, $exitStatus, $output); + $this->assertLoaded(true); + Phake::verify(Arsse::$user)->list; + } + + public function provideUserList() { + return []; + $list = ["john.doe@example.com", "jane.doe@example.com"]; + $str = implode(PHP_EOL, $list); + return [ + ["arsse.php user list", $list, 0, $str], + ["arsse.php user", $list, 0, $str], + ["arsse.php user list", [], 0, ""], + ["arsse.php user", [], 0, ""], + ]; + } +} diff --git a/tests/cases/Conf/TestConf.php b/tests/cases/Conf/TestConf.php index 73bec5f..5aa56d8 100644 --- a/tests/cases/Conf/TestConf.php +++ b/tests/cases/Conf/TestConf.php @@ -135,6 +135,14 @@ class TestConf extends \JKingWeb\Arsse\Test\AbstractTest { $this->assertArraySubset($exp, $arr); } + /** @depends testExportToFile */ + public function testExportToStdout() { + $conf = new Conf(self::$path."confGood"); + $conf->exportFile(self::$path."confGood"); + $this->expectOutputString(file_get_contents(self::$path."confGood")); + $conf->exportFile("php://output"); + } + public function testExportToFileWithoutWritePermission() { $this->assertException("fileUnwritable", "Conf"); (new Conf)->exportFile(self::$path."confUnreadable"); diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 7661381..effe34e 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -9,6 +9,7 @@ namespace JKingWeb\Arsse\Test; use JKingWeb\Arsse\Exception; use JKingWeb\Arsse\Arsse; use JKingWeb\Arsse\Conf; +use JKingWeb\Arsse\CLI; use JKingWeb\Arsse\Misc\Date; use Psr\Http\Message\MessageInterface; use Psr\Http\Message\RequestInterface; @@ -27,6 +28,18 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { $this->clearData(); } + public function clearData(bool $loadLang = true) { + date_default_timezone_set("America/Toronto"); + $r = new \ReflectionClass(\JKingWeb\Arsse\Arsse::class); + $props = array_keys($r->getStaticProperties()); + foreach ($props as $prop) { + Arsse::$$prop = null; + } + if ($loadLang) { + Arsse::$lang = new \JKingWeb\Arsse\Lang(); + } + } + public function setConf(array $conf = []) { Arsse::$conf = (new Conf)->import($conf); } @@ -70,6 +83,13 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { $this->assertEquals($exp->getHeaders(), $act->getHeaders(), $text); } + public function assertTime($exp, $test, string $msg = null) { + $test = $this->approximateTime($exp, $test); + $exp = Date::transform($exp, "iso8601"); + $test = Date::transform($test, "iso8601"); + $this->assertSame($exp, $test, $msg); + } + public function approximateTime($exp, $act) { if (is_null($act)) { return null; @@ -85,24 +105,4 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { return $act; } } - - public function assertTime($exp, $test, string $msg = null) { - $test = $this->approximateTime($exp, $test); - $exp = Date::transform($exp, "iso8601"); - $test = Date::transform($test, "iso8601"); - $this->assertSame($exp, $test, $msg); - } - - public function clearData(bool $loadLang = true): bool { - date_default_timezone_set("America/Toronto"); - $r = new \ReflectionClass(\JKingWeb\Arsse\Arsse::class); - $props = array_keys($r->getStaticProperties()); - foreach ($props as $prop) { - Arsse::$$prop = null; - } - if ($loadLang) { - Arsse::$lang = new \JKingWeb\Arsse\Lang(); - } - return true; - } } diff --git a/tests/phpunit.xml b/tests/phpunit.xml index b58b0cd..78406a0 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -96,8 +96,9 @@ cases/REST/TinyTinyRSS/TestIcon.php cases/REST/TinyTinyRSS/PDO/TestAPI.php - + cases/Service/TestService.php + cases/CLI/TestCLI.php - \ No newline at end of file + diff --git a/vendor-bin/phpunit/composer.json b/vendor-bin/phpunit/composer.json index 1d56423..2fe20f7 100644 --- a/vendor-bin/phpunit/composer.json +++ b/vendor-bin/phpunit/composer.json @@ -2,6 +2,7 @@ "require": { "phpunit/phpunit": "^6.5", "phake/phake": "^3.0", + "clue/arguments": "^2.0", "mikey179/vfsStream": "^1.6", "webmozart/glob": "^4.1" } diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index bf99b96..53d62ca 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -4,8 +4,58 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "2feb94beae7c769e2df081af57c89fed", + "content-hash": "4252b3d7817c9a4a5f60ac81f28202e2", "packages": [ + { + "name": "clue/arguments", + "version": "v2.0.0", + "source": { + "type": "git", + "url": "https://github.com/clue/php-arguments.git", + "reference": "eb8356918bc51ac7e595e4ad92a2bc1c1d2754c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/php-arguments/zipball/eb8356918bc51ac7e595e4ad92a2bc1c1d2754c2", + "reference": "eb8356918bc51ac7e595e4ad92a2bc1c1d2754c2", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Clue\\Arguments\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@lueck.tv" + } + ], + "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", + "keywords": [ + "args", + "arguments", + "argv", + "command", + "command line", + "explode", + "parse", + "split" + ], + "time": "2016-12-18T14:37:39+00:00" + }, { "name": "doctrine/instantiator", "version": "1.0.5",