diff --git a/lib/AbstractException.php b/lib/AbstractException.php index ebbd6915..cd5fcc8c 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -19,6 +19,7 @@ abstract class AbstractException extends \Exception { "Lang/Exception.fileCorrupt" => 10104, "Lang/Exception.stringMissing" => 10105, "Lang/Exception.stringInvalid" => 10106, + "Lang/Exception.dataInvalid" => 10107, "Db/Exception.extMissing" => 10201, "Db/Exception.fileMissing" => 10202, "Db/Exception.fileUnusable" => 10203, @@ -62,6 +63,8 @@ abstract class AbstractException extends \Exception { "Conf/Exception.fileUnwritable" => 10304, "Conf/Exception.fileUncreatable" => 10305, "Conf/Exception.fileCorrupt" => 10306, + "Conf/Exception.typeMismatch" => 10311, + "Conf/Exception.semanticMismatch" => 10312, "User/Exception.functionNotImplemented" => 10401, "User/Exception.doesNotExist" => 10402, "User/Exception.alreadyExists" => 10403, diff --git a/lib/Conf.php b/lib/Conf.php index 271a3cd4..e241d470 100644 --- a/lib/Conf.php +++ b/lib/Conf.php @@ -7,6 +7,8 @@ declare(strict_types=1); namespace JKingWeb\Arsse; +use JKingWeb\Arsse\Misc\ValueInfo as Value; + /** Class for loading, saving, and querying configuration * * The Conf class serves both as a means of importing and querying configuration information, as well as a source for default parameters when a configuration file does not specify a value. @@ -15,19 +17,19 @@ class Conf { /** @var string Default language to use for logging and errors */ public $lang = "en"; - /** @var string Class of the database driver in use (SQLite3 by default) */ - public $dbDriver = Db\SQLite3\Driver::class; - /** @var boolean Whether to attempt to automatically update the database when updated to a new version with schema changes */ + /** @var string The database driver to use, one of "sqlite3", "postgresql", or "mysql". A fully-qualified class name may also be used for custom drivers */ + public $dbDriver = "sqlite3"; + /** @var boolean Whether to attempt to automatically update the database when upgrading to a new version with schema changes */ public $dbAutoUpdate = true; - /** @var float Number of seconds to wait before returning a timeout error when connecting to a database (zero waits forever; not applicable to SQLite) */ + /** @var \DateInterval Number of seconds to wait before returning a timeout error when connecting to a database (zero waits forever; not applicable to SQLite) */ public $dbTimeoutConnect = 5.0; - /** @var float Number of seconds to wait before returning a timeout error when executing a database operation (zero waits forever; not applicable to SQLite) */ + /** @var \DateInterval Number of seconds to wait before returning a timeout error when executing a database operation (zero waits forever; not applicable to SQLite) */ public $dbTimeoutExec = 0.0; /** @var string|null Full path and file name of SQLite database (if using SQLite) */ public $dbSQLite3File = null; /** @var string Encryption key to use for SQLite database (if using a version of SQLite with SEE) */ public $dbSQLite3Key = ""; - /** @var float 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) */ + /** @var \DateInterval 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 = 60.0; /** @var string Host name, address, or socket path of PostgreSQL database server (if using PostgreSQL) */ public $dbPostgreSQLHost = ""; @@ -43,21 +45,21 @@ class Conf { public $dbPostgreSQLSchema = ""; /** @var string Service file entry to use (if using PostgreSQL); if using a service entry all above parameters except schema are ignored */ public $dbPostgreSQLService = ""; - /** @var string Host name, address, or socket path of MySQL/MariaDB database server (if using MySQL/MariaDB) */ + /** @var string Host name or address of MySQL database server (if using MySQL) */ public $dbMySQLHost = "localhost"; - /** @var string Log-in user name for MySQL/MariaDB database server (if using MySQL/MariaDB) */ + /** @var string Log-in user name for MySQL database server (if using MySQL) */ public $dbMySQLUser = "arsse"; - /** @var string Log-in password for MySQL/MariaDB database server (if using MySQL/MariaDB) */ + /** @var string Log-in password for MySQL database server (if using MySQL) */ public $dbMySQLPass = ""; - /** @var integer Listening port for MySQL/MariaDB database server (if using MySQL/MariaDB over TCP) */ + /** @var integer Listening port for MySQL database server (if using MySQL over TCP) */ public $dbMySQLPort = 3306; - /** @var string Database name on MySQL/MariaDB database server (if using MySQL/MariaDB) */ + /** @var string Database name on MySQL database server (if using MySQL) */ public $dbMySQLDb = "arsse"; /** @var string Unix domain socket or named pipe to use for MySQL when not connecting over TCP */ public $dbMySQLSocket = ""; - /** @var string Class of the user management driver in use (Internal by default) */ - public $userDriver = User\Internal\Driver::class; + /** @var string The user management driver to use, currently only "internal". A fully-qualified class name may also be used for custom drivers */ + public $userDriver = "internal"; /** @var boolean Whether users are already authenticated by the Web server before the application is executed */ public $userPreAuth = false; /** @var boolean Whether to require successful HTTP authentication before processing API-level authentication for protocols which have any. Normally the Tiny Tiny RSS relies on its own session-token authentication scheme, for example */ @@ -66,43 +68,43 @@ class Conf { public $userTempPasswordLength = 20; /** @var boolean Whether invalid or expired API session tokens should prevent logging in when HTTP authentication is used, for protocol which implement their own authentication */ public $userSessionEnforced = true; - /** @var string Period of inactivity after which log-in sessions should be considered invalid, as an ISO 8601 duration (default: 24 hours) + /** @var \DateInterval Period of inactivity after which log-in sessions should be considered invalid, as an ISO 8601 duration (default: 24 hours) * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ public $userSessionTimeout = "PT24H"; - /** @var string Maximum lifetime of log-in sessions regardless of activity, as an ISO 8601 duration (default: 7 days); + /** @var \DateInterval Maximum lifetime of log-in sessions regardless of activity, as an ISO 8601 duration (default: 7 days); * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ public $userSessionLifetime = "P7D"; - /** @var string Class of the background feed update service driver in use (Forking by default) */ - public $serviceDriver = Service\Forking\Driver::class; - /** @var string The interval between checks for new articles, as an ISO 8601 duration + /** @var string Feed update service driver to use, one of "serial", "subprocess", or "curl". A fully-qualified class name may also be used for custom drivers */ + public $serviceDriver = "subprocess"; + /** @var \DateInterval The interval between checks for new articles, as an ISO 8601 duration * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ public $serviceFrequency = "PT2M"; /** @var integer Number of concurrent feed updates to perform */ public $serviceQueueWidth = 5; /** @var string The base server address (with scheme, host, port if necessary, and terminal slash) to connect to the server when performing feed updates using cURL */ public $serviceCurlBase = "http://localhost/"; - /** @var string The user name to use when performing feed updates using cURL; if none is provided, a temporary name and password will be stored in the database (this is not compatible with pre-authentication) */ - public $serviceCurlUser = null; + /** @var string The user name to use when performing feed updates using cURL */ + public $serviceCurlUser = ""; /** @var string The password to use when performing feed updates using cURL */ - public $serviceCurlPassword = null; + public $serviceCurlPassword = ""; - /** @var integer Number of seconds to wait for data when fetching feeds from foreign servers */ + /** @var \DateInterval Number of seconds to wait for data when fetching feeds from foreign servers */ public $fetchTimeout = 10; /** @var integer Maximum size, in bytes, of data when fetching feeds from foreign servers */ public $fetchSizeLimit = 2 * 1024 * 1024; /** @var boolean Whether to allow the possibility of fetching full article contents using an item's URL. Whether fetching will actually happen is also governed by a per-feed setting */ public $fetchEnableScraping = true; /** @var string|null User-Agent string to use when fetching feeds from foreign servers */ - public $fetchUserAgentString; + public $fetchUserAgentString = null; - /** @var string When to delete a feed from the database after all its subscriptions have been deleted, as an ISO 8601 duration (default: 24 hours; empty string for never) + /** @var \DateInterval|null When to delete a feed from the database after all its subscriptions have been deleted, as an ISO 8601 duration (default: 24 hours; null for never) * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ public $purgeFeeds = "PT24H"; - /** @var string When to delete an unstarred article in the database after it has been marked read by all users, as an ISO 8601 duration (default: 7 days; empty string for never) + /** @var \DateInterval|null When to delete an unstarred article in the database after it has been marked read by all users, as an ISO 8601 duration (default: 7 days; null for never) * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ public $purgeArticlesRead = "P7D"; - /** @var string When to delete an unstarred article in the database regardless of its read state, as an ISO 8601 duration (default: 21 days; empty string for never) + /** @var \DateInterval|null When to delete an unstarred article in the database regardless of its read state, as an ISO 8601 duration (default: 21 days; null for never) * @see https://en.wikipedia.org/wiki/ISO_8601#Durations */ public $purgeArticlesUnread = "P21D"; @@ -113,10 +115,26 @@ class Conf { /** @var string Space-separated list of origins from which to deny cross-origin resource sharing */ public $httpOriginsDenied = ""; + const TYPE_NAMES = [ + Value::T_BOOL => "boolean", + Value::T_STRING => "string", + Value::T_FLOAT => "float", + VALUE::T_INT => "integer", + Value::T_INTERVAL => "interval", + ]; + + protected static $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->$prop = $this->propertyImport($prop, $this->$prop); + } if ($import_file !== "") { $this->importFile($import_file); } @@ -124,7 +142,7 @@ class Conf { /** Layers configuration data from a file into an existing object * - * The file must be a PHP script which return an array with keys that match the properties of the Conf class. Malformed files will throw an exception; unknown keys are silently ignored. Files may be imported is succession, though this is not currently used. + * The file must be a PHP script which returns an array with keys that match the properties of the Conf class. Malformed files will throw an exception; unknown keys are silently accepted. Files may be imported in succession, though this is not currently used. * @param string $file Full path and file name for the file to import */ public function importFile(string $file): self { if (!file_exists($file)) { @@ -143,16 +161,22 @@ class Conf { if (!is_array($arr)) { throw new Conf\Exception("fileCorrupt", $file); } - return $this->import($arr); + return $this->importData($arr, $file); } /** Layers configuration data from an associative array into an existing object * - * The input array must have keys that match the properties of the Conf class; unknown keys are silently ignored. Arrays may be imported is succession, though this is not currently used. + * The input array must have keys that match the properties of the Conf class; unknown keys are silently accepted. Arrays may be imported in succession, though this is not currently used. * @param mixed[] $arr Array of configuration parameters to export */ public function import(array $arr): self { + $file = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 1)[0]['file'] ?? ""; + return $this->importData($arr, $file); + } + + /** Layers configuration data from an associative array into an existing object */ + protected function importData(array $arr, string $file): self { foreach ($arr as $key => $value) { - $this->$key = $value; + $this->$key = $this->propertyImport($key, $value, $file); } return $this; } @@ -165,7 +189,7 @@ class Conf { $conf = new \ReflectionObject($this); foreach ($conf->getProperties(\ReflectionProperty::IS_PUBLIC) as $prop) { $name = $prop->name; - // add the property to the output if the value is scalar (or null) and either: + // add the property to the output if the value is of a supported type and either: // 1. full output has been requested // 2. the property is not defined in the class // 3. it differs from the default @@ -211,4 +235,79 @@ class Conf { } return true; } + + /** Caches information about configuration properties for later access */ + protected function propertyDiscover(): array { + $out = []; + $rc = new \ReflectionClass($this); + foreach ($rc->getProperties(\ReflectionProperty::IS_PUBLIC) as $p) { + if (preg_match("/@var\s+((?:int(eger)?|float|bool(ean)?|string|\\\\DateInterval)(?:\|null)?)[^\[]/", $p->getDocComment(), $match)) { + $match = explode("|", $match[1]); + $nullable = (sizeof($match) > 1); + $type = [ + 'string' => Value::T_STRING | Value::M_STRICT, + 'integer' => Value::T_INT | Value::M_STRICT, + 'boolean' => Value::T_BOOL | Value::M_STRICT, + 'float' => Value::T_FLOAT | Value::M_STRICT, + '\\DateInterval' => Value::T_INTERVAL | Value::M_LOOSE, + ][$match[0]]; + if ($nullable) { + $type |= Value::M_NULL; + } + } else { + $type = Value::T_MIXED; // @codeCoverageIgnore + } + $out[$p->name] = ['name' => $match[0], 'const' => $type]; + } + return $out; + } + + protected function propertyImport(string $key, $value, string $file = "") { + try { + $typeName = static::$types[$key]['name'] ?? "mixed"; + $typeConst = static::$types[$key]['const'] ?? Value::T_MIXED; + if ($typeName === "\\DateInterval") { + // date intervals have special handling: if the existing value (ultimately, the default value) + // is an integer or float, the new value should be imported as numeric. If the new value is a string + // it is first converted to an interval and then converted to the numeric type if necessary + if (is_string($value)) { + $value = Value::normalize($value, Value::T_INTERVAL | Value::M_STRICT); + } + switch (gettype($this->$key)) { + case "integer": + return Value::normalize($value, Value::T_INT | Value::M_STRICT); + case "double": + return Value::normalize($value, Value::T_FLOAT | Value::M_STRICT); + case "string": + case "object": + return $value; + default: + throw new ExceptionType("strictFailure"); // @codeCoverageIgnore + } + } + $value = Value::normalize($value, $typeConst); + switch ($key) { + case "dbDriver": + $driver = $driver ?? Database::DRIVER_NAMES[strtolower($value)] ?? $value; + $interface = $interface ?? Db\Driver::class; + // no break + case "userDriver": + $driver = $driver ?? User::DRIVER_NAMES[strtolower($value)] ?? $value; + $interface = $interface ?? User\Driver::class; + // no break + case "serviceDriver": + $driver = $driver ?? Service::DRIVER_NAMES[strtolower($value)] ?? $value; + $interface = $interface ?? Service\Driver::class; + if (!is_subclass_of($driver, $interface)) { + throw new Conf\Exception("semanticMismatch", ['param' => $key, 'file' => $file]); + } + return $driver; + } + return $value; + } catch (ExceptionType $e) { + $nullable = (int) (bool) (static::$types[$key] & Value::M_NULL); + $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]); + } + } } diff --git a/lib/Database.php b/lib/Database.php index 9fba4daa..c3ac4c06 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -16,6 +16,11 @@ use JKingWeb\Arsse\Misc\ValueInfo; class Database { const SCHEMA_VERSION = 4; const LIMIT_ARTICLES = 50; + const DRIVER_NAMES = [ + 'sqlite3' => \JKingWeb\Arsse\Db\SQLite3\Driver::class, + 'postgresql' => \JKingWeb\Arsse\Db\PostgreSQL\Driver::class, + 'mysql' => \JKingWeb\Arsse\Db\MySQL\Driver::class, + ]; /** @var Db\Driver */ public $db; @@ -760,14 +765,13 @@ class Database { $this->db->query("UPDATE arsse_feeds set orphaned = null where exists(SELECT id from arsse_subscriptions where feed = arsse_feeds.id)"); // next mark any newly orphaned feeds with the current date and time $this->db->query("UPDATE arsse_feeds set orphaned = CURRENT_TIMESTAMP where orphaned is null and not exists(SELECT id from arsse_subscriptions where feed = arsse_feeds.id)"); - // finally delete feeds that have been orphaned longer than the retention period - $limit = Date::normalize("now"); + // finally delete feeds that have been orphaned longer than the retention period, if a a purge threshold has been specified if (Arsse::$conf->purgeFeeds) { - // if there is a retention period specified, compute it; otherwise feed are deleted immediatelty - $limit = Date::sub(Arsse::$conf->purgeFeeds, $limit); + $limit = Date::sub(Arsse::$conf->purgeFeeds); + $out = (bool) $this->db->prepare("DELETE from arsse_feeds where orphaned <= ?", "datetime")->run($limit); + } else { + $out = false; } - $out = (bool) $this->db->prepare("DELETE from arsse_feeds where orphaned <= ?", "datetime")->run($limit); - // commit changes and return $tr->commit(); return $out; } diff --git a/lib/Db/MySQL/Driver.php b/lib/Db/MySQL/Driver.php index ea358b17..e0548001 100644 --- a/lib/Db/MySQL/Driver.php +++ b/lib/Db/MySQL/Driver.php @@ -28,12 +28,12 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { if (!static::requirementsMet()) { throw new Exception("extMissing", static::driverName()); // @codeCoverageIgnore } - $host = strtolower(!strlen((string) Arsse::$conf->dbMySQLHost) ? "localhost" : Arsse::$conf->dbMySQLHost); - $socket = strlen((string) Arsse::$conf->dbMySQLSocket) ? Arsse::$conf->dbMySQLSocket : ini_get("mysqli.default_socket"); - $user = Arsse::$conf->dbMySQLUser ?? ""; - $pass = Arsse::$conf->dbMySQLPass ?? ""; - $port = Arsse::$conf->dbMySQLPost ?? 3306; - $db = Arsse::$conf->dbMySQLDb ?? "arsse"; + $host = strtolower(!strlen(Arsse::$conf->dbMySQLHost) ? "localhost" : Arsse::$conf->dbMySQLHost); + $socket = strlen(Arsse::$conf->dbMySQLSocket) ? Arsse::$conf->dbMySQLSocket : ini_get("mysqli.default_socket"); + $user = Arsse::$conf->dbMySQLUser; + $pass = Arsse::$conf->dbMySQLPass; + $port = Arsse::$conf->dbMySQLPort; + $db = Arsse::$conf->dbMySQLDb; // make the connection $this->makeConnection($db, $user, $pass, $host, $port, $socket); // set session variables diff --git a/lib/Db/PostgreSQL/Driver.php b/lib/Db/PostgreSQL/Driver.php index 0fa3bd19..8886546f 100644 --- a/lib/Db/PostgreSQL/Driver.php +++ b/lib/Db/PostgreSQL/Driver.php @@ -25,13 +25,13 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { if (!static::requirementsMet()) { throw new Exception("extMissing", static::driverName()); // @codeCoverageIgnore } - $user = $user ?? Arsse::$conf->dbPostgreSQLUser; - $pass = $pass ?? Arsse::$conf->dbPostgreSQLPass; - $db = $db ?? Arsse::$conf->dbPostgreSQLDb; - $host = $host ?? Arsse::$conf->dbPostgreSQLHost; - $port = $port ?? Arsse::$conf->dbPostgreSQLPort; - $schema = $schema ?? Arsse::$conf->dbPostgreSQLSchema; - $service = $service ?? Arsse::$conf->dbPostgreSQLService; + $user = Arsse::$conf->dbPostgreSQLUser; + $pass = Arsse::$conf->dbPostgreSQLPass; + $db = Arsse::$conf->dbPostgreSQLDb; + $host = Arsse::$conf->dbPostgreSQLHost; + $port = Arsse::$conf->dbPostgreSQLPort; + $schema = Arsse::$conf->dbPostgreSQLSchema; + $service = Arsse::$conf->dbPostgreSQLService; $this->makeConnection($user, $pass, $db, $host, $port, $service); foreach (static::makeSetupQueries($schema) as $q) { $this->exec($q); @@ -42,7 +42,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { $base = [ 'client_encoding' => "UTF8", 'application_name' => "arsse", - 'connect_timeout' => (string) ceil(Arsse::$conf->dbTimeoutConnect ?? 0), + 'connect_timeout' => (string) ceil(Arsse::$conf->dbTimeoutConnect), ]; $out = []; if ($service != "") { diff --git a/lib/Db/SQLite3/Driver.php b/lib/Db/SQLite3/Driver.php index 6c5764ce..612072bf 100644 --- a/lib/Db/SQLite3/Driver.php +++ b/lib/Db/SQLite3/Driver.php @@ -28,8 +28,8 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { throw new Exception("extMissing", static::driverName()); // @codeCoverageIgnore } // if no database file is specified in the configuration, use a suitable default - $dbFile = $dbFile ?? Arsse::$conf->dbSQLite3File ?? \JKingWeb\Arsse\BASE."arsse.db"; - $dbKey = $dbKey ?? Arsse::$conf->dbSQLite3Key; + $dbFile = Arsse::$conf->dbSQLite3File ?? \JKingWeb\Arsse\BASE."arsse.db"; + $dbKey = Arsse::$conf->dbSQLite3Key; $timeout = Arsse::$conf->dbSQLite3Timeout * 1000; try { $this->makeConnection($dbFile, $dbKey); @@ -55,7 +55,7 @@ class Driver extends \JKingWeb\Arsse\Db\AbstractDriver { throw new Exception("fileCorrupt", $dbFile); } // set the timeout - $timeout = (int) ceil((Arsse::$conf->dbSQLite3Timeout ?? 0) * 1000); + $timeout = (int) ceil(Arsse::$conf->dbSQLite3Timeout * 1000); $this->setTimeout($timeout); // set other initial options $this->exec("PRAGMA foreign_keys = yes"); diff --git a/lib/Lang.php b/lib/Lang.php index 66687ae0..b636f3b8 100644 --- a/lib/Lang.php +++ b/lib/Lang.php @@ -26,6 +26,8 @@ class Lang { protected $locale = ""; // the currently loaded locale protected $loaded = []; // the cascade of loaded locale file names protected $strings = self::REQUIRED; // the loaded locale strings, merged + /** @var \MessageFormatter */ + protected $formatter; public function __construct(string $path = BASE."locale".DIRECTORY_SEPARATOR) { $this->path = $path; @@ -101,9 +103,13 @@ class Lang { } elseif (!is_array($vars)) { $vars = [$vars]; } - $msg = \MessageFormatter::formatMessage($this->locale, $msg, $vars); + $this->formatter = $this->formatter ?? new \MessageFormatter($this->locale, "Initial message"); + if (!$this->formatter->setPattern($msg)) { + throw new Lang\Exception("stringInvalid", ['error' => $this->formatter->getErrorMessage(), 'msgID' => $msgID, 'fileList' => implode(", ", $this->loaded)]); + } + $msg = $this->formatter->format($vars); if ($msg===false) { - throw new Lang\Exception("stringInvalid", ['msgID' => $msgID, 'fileList' => implode(", ", $this->loaded)]); + throw new Lang\Exception("dataInvalid", ['error' => $this->formatter->getErrorMessage(), 'msgID' => $msgID, 'fileList' => implode(", ", $this->loaded)]); // @codeCoverageIgnore } return $msg; } @@ -159,6 +165,7 @@ class Lang { $this->strings = self::REQUIRED; $this->locale = $this->wanted; $this->synched = true; + $this->formatter = null; return true; } // decompose the requested locale from specific to general, building a list of files to load @@ -217,6 +224,7 @@ class Lang { $this->loaded = $loaded; $this->locale = $this->wanted; $this->synched = true; + $this->formatter = null; return true; } } diff --git a/lib/Misc/Date.php b/lib/Misc/Date.php index 323b632c..f4b34850 100644 --- a/lib/Misc/Date.php +++ b/lib/Misc/Date.php @@ -25,16 +25,17 @@ class Date { return ValueInfo::normalize($date, ValueInfo::T_DATE, $inFormat); } - public static function add(string $interval, $date = "now") { + public static function add($interval, $date = "now") { return self::modify("add", $interval, $date); } - public static function sub(string $interval, $date = "now") { + public static function sub($interval, $date = "now") { return self::modify("sub", $interval, $date); } - protected static function modify(string $func, string $interval, $date) { + protected static function modify(string $func, $interval, $date) { $date = self::normalize($date); - return $date ? $date->$func(new \DateInterval($interval)) : null; + $interval = (!$interval instanceof \DateInterval) ? ValueInfo::normalize($interval, ValueInfo::T_INTERVAL) : $interval; + return $date ? $date->$func($interval) : null; } } diff --git a/lib/Misc/ValueInfo.php b/lib/Misc/ValueInfo.php index 8ac52ba6..3ce76e77 100644 --- a/lib/Misc/ValueInfo.php +++ b/lib/Misc/ValueInfo.php @@ -30,6 +30,7 @@ class ValueInfo { const T_ARRAY = 7; // convert to array const T_INTERVAL = 8; // convert to time interval // normalization modes + const M_LOOSE = 0; const M_NULL = 1 << 28; // pass nulls through regardless of target type const M_DROP = 1 << 29; // drop the value (return null) if the type doesn't match const M_STRICT = 1 << 30; // throw an exception if the type doesn't match diff --git a/lib/REST.php b/lib/REST.php index ed15060c..1bc395f3 100644 --- a/lib/REST.php +++ b/lib/REST.php @@ -146,7 +146,7 @@ class REST { } public function challenge(ResponseInterface $res, string $realm = null): ResponseInterface { - $realm = $realm ?? Arsse::$conf->httpRealm ?? "Default"; + $realm = $realm ?? Arsse::$conf->httpRealm; return $res->withAddedHeader("WWW-Authenticate", 'Basic realm="'.$realm.'"'); } @@ -205,8 +205,8 @@ class REST { } public function corsNegotiate(RequestInterface $req, string $allowed = null, string $denied = null): bool { - $allowed = trim($allowed ?? Arsse::$conf->httpOriginsAllowed ?? ""); - $denied = trim($denied ?? Arsse::$conf->httpOriginsDenied ?? ""); + $allowed = trim($allowed ?? Arsse::$conf->httpOriginsAllowed); + $denied = trim($denied ?? Arsse::$conf->httpOriginsDenied); // continue if at least one origin is allowed if ($allowed) { // continue if the request has exactly one Origin header diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index de8b6b3d..4ddea6fa 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -461,7 +461,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { } $children = $c['children'] ? $this->enumerateCategories($cats, $subs, $c['id'], $all) : ['list' => [], 'feeds' => 0]; $feeds = $c['feeds'] ? $this->enumerateFeeds($subs, $c['id']) : []; - $count = sizeof($feeds) + $children['feeds']; + $count = sizeof($feeds) + (int) $children['feeds']; $out[] = [ 'name' => $c['name'], 'id' => "CAT:".$c['id'], @@ -472,7 +472,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { 'unread' => 0, 'child_unread' => 0, 'checkbox' => false, - 'param' => Arsse::$lang->msg("API.TTRSS.FeedCount", $count), + 'param' => Arsse::$lang->msg("API.TTRSS.FeedCount", (string) $count), 'items' => array_merge($children['list'], $feeds), ]; $feedTotal += $count; diff --git a/lib/Service.php b/lib/Service.php index 5a51fc1a..bc752aef 100644 --- a/lib/Service.php +++ b/lib/Service.php @@ -9,6 +9,11 @@ namespace JKingWeb\Arsse; use JKingWeb\Arsse\Misc\Date; class Service { + const DRIVER_NAMES = [ + 'serial' => \JKingWeb\Arsse\Service\Serial\Driver::class, + 'subprocess' => \JKingWeb\Arsse\Service\Subprocess\Driver::class, + 'curl' => \JKingWeb\Arsse\Service\Curl\Driver::class, + ]; /** @var Service\Driver */ protected $drv; @@ -27,18 +32,10 @@ class Service { return $classes; } - public static function interval(): \DateInterval { - try { - return new \DateInterval(Arsse::$conf->serviceFrequency); - } catch (\Exception $e) { - return new \DateInterval("PT2M"); - } - } - public function __construct() { $driver = Arsse::$conf->serviceDriver; $this->drv = new $driver(); - $this->interval = static::interval(); + $this->interval = Arsse::$conf->serviceFrequency; } public function watch(bool $loop = true): \DateTimeInterface { @@ -77,8 +74,8 @@ class Service { // convert the check-in timestamp to a DateTime instance $checkin = Date::normalize($checkin, "sql"); // get the checking interval - $int = static::interval(); - // subtract twice the checking interval from the current time to the earliest acceptable check-in time + $int = Arsse::$conf->serviceFrequency; + // subtract twice the checking interval from the current time to yield the earliest acceptable check-in time $limit = new \DateTime(); $limit->sub($int); $limit->sub($int); diff --git a/lib/Service/Internal/Driver.php b/lib/Service/Serial/Driver.php similarity index 89% rename from lib/Service/Internal/Driver.php rename to lib/Service/Serial/Driver.php index 06e91cef..df3580c8 100644 --- a/lib/Service/Internal/Driver.php +++ b/lib/Service/Serial/Driver.php @@ -4,7 +4,7 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\Service\Internal; +namespace JKingWeb\Arsse\Service\Serial; use JKingWeb\Arsse\Arsse; @@ -12,7 +12,7 @@ class Driver implements \JKingWeb\Arsse\Service\Driver { protected $queue = []; public static function driverName(): string { - return Arsse::$lang->msg("Driver.Service.Internal.Name"); + return Arsse::$lang->msg("Driver.Service.Serial.Name"); } public static function requirementsMet(): bool { diff --git a/lib/Service/Forking/Driver.php b/lib/Service/Subprocess/Driver.php similarity index 91% rename from lib/Service/Forking/Driver.php rename to lib/Service/Subprocess/Driver.php index 4a8e7bf6..a6572322 100644 --- a/lib/Service/Forking/Driver.php +++ b/lib/Service/Subprocess/Driver.php @@ -4,7 +4,7 @@ * See LICENSE and AUTHORS files for details */ declare(strict_types=1); -namespace JKingWeb\Arsse\Service\Forking; +namespace JKingWeb\Arsse\Service\Subprocess; use JKingWeb\Arsse\Arsse; @@ -12,7 +12,7 @@ class Driver implements \JKingWeb\Arsse\Service\Driver { protected $queue = []; public static function driverName(): string { - return Arsse::$lang->msg("Driver.Service.Forking.Name"); + return Arsse::$lang->msg("Driver.Service.Subprocess.Name"); } public static function requirementsMet(): bool { diff --git a/lib/User.php b/lib/User.php index e5d8e176..d7aae1cc 100644 --- a/lib/User.php +++ b/lib/User.php @@ -9,6 +9,10 @@ namespace JKingWeb\Arsse; use PasswordGenerator\Generator as PassGen; class User { + const DRIVER_NAMES = [ + 'internal' => \JKingWeb\Arsse\User\Internal\Driver::class, + ]; + public $id = null; /** diff --git a/locale/en.php b/locale/en.php index f00c29b5..dcaa8484 100644 --- a/locale/en.php +++ b/locale/en.php @@ -16,7 +16,7 @@ return [ 'API.TTRSS.Feed.Published' => 'Published articles', 'API.TTRSS.Feed.Archived' => 'Archived articles', 'API.TTRSS.Feed.Read' => 'Recently read', - 'API.TTRSS.FeedCount' => '{0, select, 1 {(1 feed)} other {({0} feeds)}}', + 'API.TTRSS.FeedCount' => '({0, number} {0, plural, one {feed} other {feeds}})', 'Driver.Db.SQLite3.Name' => 'SQLite 3', 'Driver.Db.SQLite3PDO.Name' => 'SQLite 3 (PDO)', @@ -24,74 +24,16 @@ return [ 'Driver.Db.PostgreSQLPDO.Name' => 'PostgreSQL (PDO)', 'Driver.Db.MySQL.Name' => 'MySQL', 'Driver.Db.MySQLPDO.Name' => 'MySQL (PDO)', - 'Driver.Service.Curl.Name' => 'HTTP (curl)', - 'Driver.Service.Internal.Name' => 'Internal', + + 'Driver.Service.Serial.Name' => 'Serialized', + 'Driver.Service.Subprocess.Name' => 'Concurrent subprocess', + 'Driver.Service.Curl.Name' => 'Concurrent HTTP (curl)', + 'Driver.User.Internal.Name' => 'Internal', - 'HTTP.Status.100' => 'Continue', - 'HTTP.Status.101' => 'Switching Protocols', - 'HTTP.Status.102' => 'Processing', - 'HTTP.Status.200' => 'OK', - 'HTTP.Status.201' => 'Created', - 'HTTP.Status.202' => 'Accepted', - 'HTTP.Status.203' => 'Non-Authoritative Information', - 'HTTP.Status.204' => 'No Content', - 'HTTP.Status.205' => 'Reset Content', - 'HTTP.Status.206' => 'Partial Content', - 'HTTP.Status.207' => 'Multi-Status', - 'HTTP.Status.208' => 'Already Reported', - 'HTTP.Status.226' => 'IM Used', - 'HTTP.Status.300' => 'Multiple Choice', - 'HTTP.Status.301' => 'Moved Permanently', - 'HTTP.Status.302' => 'Found', - 'HTTP.Status.303' => 'See Other', - 'HTTP.Status.304' => 'Not Modified', - 'HTTP.Status.305' => 'Use Proxy', - 'HTTP.Status.306' => 'Switch Proxy', - 'HTTP.Status.307' => 'Temporary Redirect', - 'HTTP.Status.308' => 'Permanent Redirect', - 'HTTP.Status.400' => 'Bad Request', - 'HTTP.Status.401' => 'Unauthorized', - 'HTTP.Status.402' => 'Payment Required', - 'HTTP.Status.403' => 'Forbidden', - 'HTTP.Status.404' => 'Not Found', - 'HTTP.Status.405' => 'Method Not Allowed', - 'HTTP.Status.406' => 'Not Acceptable', - 'HTTP.Status.407' => 'Proxy Authentication Required', - 'HTTP.Status.408' => 'Request Timeout', - 'HTTP.Status.409' => 'Conflict', - 'HTTP.Status.410' => 'Gone', - 'HTTP.Status.411' => 'Length Required', - 'HTTP.Status.412' => 'Precondition Failed', - 'HTTP.Status.413' => 'Payload Too Large', - 'HTTP.Status.414' => 'URL Too Long', - 'HTTP.Status.415' => 'Unsupported Media Type', - 'HTTP.Status.416' => 'Range Not Satisfiable', - 'HTTP.Status.417' => 'Expectation Failed', - 'HTTP.Status.421' => 'Misdirected Request', - 'HTTP.Status.422' => 'Unprocessable Entity', - 'HTTP.Status.423' => 'Locked', - 'HTTP.Status.424' => 'Failed Depedency', - 'HTTP.Status.426' => 'Upgrade Required', - 'HTTP.Status.428' => 'Precondition Failed', - 'HTTP.Status.429' => 'Too Many Requests', - 'HTTP.Status.431' => 'Request Header Fields Too Large', - 'HTTP.Status.451' => 'Unavailable For Legal Reasons', - 'HTTP.Status.500' => 'Internal Server Error', - 'HTTP.Status.501' => 'Not Implemented', - 'HTTP.Status.502' => 'Bad Gateway', - 'HTTP.Status.503' => 'Service Unavailable', - 'HTTP.Status.504' => 'Gateway Timeout', - 'HTTP.Status.505' => 'HTTP Version Not Supported', - 'HTTP.Status.506' => 'Variant Also Negotiates', - 'HTTP.Status.507' => 'Insufficient Storage', - 'HTTP.Status.508' => 'Loop Detected', - 'HTTP.Status.510' => 'Not Extended', - 'HTTP.Status.511' => 'Network Authentication Required', - - // this should only be encountered in testing (because tests should cover all exceptions!) + // indicates programming error 'Exception.JKingWeb/Arsse/Exception.uncoded' => 'The specified exception symbol {0} has no code specified in AbstractException.php', - // this should not usually be encountered + // indicates programming error 'Exception.JKingWeb/Arsse/Exception.unknown' => 'An unknown error has occurred', // indicates programming error 'Exception.JKingWeb/Arsse/Exception.constantUnknown' => 'Supplied constant value ({0}) is unknown or invalid in the context in which it was used', @@ -103,20 +45,36 @@ return [ 5 {datetime} 6 {string} 7 {array} - other {requested type} + 8 {DateInterval} + other {requested type {0}} }', + // indicates programming error 'Exception.JKingWeb/Arsse/ExceptionType.typeUnknown' => 'Normalization type {0} is not implemented', '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}"', 'Exception.JKingWeb/Arsse/Lang/Exception.fileCorrupt' => 'Language file "{0}" is corrupt or does not conform to expected format', 'Exception.JKingWeb/Arsse/Lang/Exception.stringMissing' => 'Message string "{msgID}" missing from all loaded language files ({fileList})', - 'Exception.JKingWeb/Arsse/Lang/Exception.stringInvalid' => 'Message string "{msgID}" is not a valid ICU message string (language files loaded: {fileList})', + 'Exception.JKingWeb/Arsse/Lang/Exception.stringInvalid' => 'Message string "{msgID}" is not a valid ICU message string (language files loaded: {fileList}): {error}', + 'Exception.JKingWeb/Arsse/Lang/Exception.dataInvalid' => 'Failed to format message message string "{msgID}" (language files loaded: {fileList}): {error}', 'Exception.JKingWeb/Arsse/Conf/Exception.fileMissing' => 'Configuration file "{0}" does not exist', 'Exception.JKingWeb/Arsse/Conf/Exception.fileUnreadable' => 'Insufficient permissions to read configuration file "{0}"', 'Exception.JKingWeb/Arsse/Conf/Exception.fileUncreatable' => 'Insufficient permissions to write new configuration file "{0}"', 'Exception.JKingWeb/Arsse/Conf/Exception.fileUnwritable' => 'Insufficient permissions to overwrite configuration file "{0}"', 'Exception.JKingWeb/Arsse/Conf/Exception.fileCorrupt' => 'Configuration file "{0}" is corrupt or does not conform to expected format', + 'Exception.JKingWeb/Arsse/Conf/Exception.typeMismatch' => + 'Configuration parameter "{param}" in file "{file}" must be {type, select, + integer {an integral number} + string {a character string} + boolean {either true or false} + float {a decimal number} + interval {an ISO 8601 time interval} + other {consistent with type "{type}"} + }{nullable, select, + 0 {} + other {, or null} + }', + 'Exception.JKingWeb/Arsse/Conf/Exception.semanticMismatch' => 'Configuration parameter "{param}" in file "{file}" is not a valid value. Consult the documentation for possible values', 'Exception.JKingWeb/Arsse/Db/Exception.extMissing' => 'Required PHP extension for driver "{0}" not installed', 'Exception.JKingWeb/Arsse/Db/Exception.fileMissing' => 'Database file "{0}" does not exist', 'Exception.JKingWeb/Arsse/Db/Exception.fileUnreadable' => 'Insufficient permissions to open database file "{0}" for reading', @@ -125,7 +83,9 @@ return [ 'Exception.JKingWeb/Arsse/Db/Exception.fileUncreatable' => 'Insufficient permissions to create new database file "{0}"', 'Exception.JKingWeb/Arsse/Db/Exception.fileCorrupt' => 'Database file "{0}" is corrupt or not a valid database', 'Exception.JKingWeb/Arsse/Db/Exception.connectionFailure' => 'Could not connect to {engine} database: {message}', + // indicates programming error 'Exception.JKingWeb/Arsse/Db/Exception.paramTypeInvalid' => 'Prepared statement parameter type "{0}" is invalid', + // indicates programming error 'Exception.JKingWeb/Arsse/Db/Exception.paramTypeUnknown' => 'Prepared statement parameter type "{0}" is valid, but not implemented', 'Exception.JKingWeb/Arsse/Db/Exception.paramTypeMissing' => 'Prepared statement parameter type for parameter #{0} was not specified', 'Exception.JKingWeb/Arsse/Db/Exception.updateManual' => @@ -149,9 +109,13 @@ return [ other {Automatic updating of the {driver_name} database failed because its version, {current}, is newer than the requested version, {target}} }', 'Exception.JKingWeb/Arsse/Db/Exception.engineErrorGeneral' => '{0}', + // indicates programming error 'Exception.JKingWeb/Arsse/Db/Exception.savepointStatusUnknown' => 'Savepoint status code {0} not implemented', + // indicates programming error 'Exception.JKingWeb/Arsse/Db/Exception.savepointInvalid' => 'Tried to {action} invalid savepoint {index}', + // indicates programming error 'Exception.JKingWeb/Arsse/Db/Exception.savepointStale' => 'Tried to {action} stale savepoint {index}', + // indicates programming error 'Exception.JKingWeb/Arsse/Db/Exception.resultReused' => 'Result set already iterated', 'Exception.JKingWeb/Arsse/Db/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', diff --git a/tests/cases/Conf/TestConf.php b/tests/cases/Conf/TestConf.php index aab95b9f..f4a44302 100644 --- a/tests/cases/Conf/TestConf.php +++ b/tests/cases/Conf/TestConf.php @@ -44,7 +44,10 @@ class TestConf extends \JKingWeb\Arsse\Test\AbstractTest { /** @depends testLoadDefaultValues */ public function testImportFromArray() { - $arr = ['lang' => "xx"]; + $arr = [ + 'lang' => "xx", + 'purgeFeeds' => "P2D", + ]; $conf = new Conf; $conf->import($arr); $this->assertEquals("xx", $conf->lang); @@ -96,6 +99,24 @@ class TestConf extends \JKingWeb\Arsse\Test\AbstractTest { $conf = new Conf(self::$path."confCorrupt"); } + public function testImportBogusValue() { + $arr = [ + 'dbAutoUpdate' => "yes, please", + ]; + $conf = new Conf; + $this->assertException("typeMismatch", "Conf"); + $conf->import($arr); + } + + public function testImportBogusDriver() { + $arr = [ + 'dbDriver' => "this driver does not exist", + ]; + $conf = new Conf; + $this->assertException("semanticMismatch", "Conf"); + $conf->import($arr); + } + public function testExportToArray() { $conf = new Conf; $conf->lang = ["en", "fr"]; // should not be exported: not scalar diff --git a/tests/cases/Database/SeriesCleanup.php b/tests/cases/Database/SeriesCleanup.php index b888686c..f8b4199b 100644 --- a/tests/cases/Database/SeriesCleanup.php +++ b/tests/cases/Database/SeriesCleanup.php @@ -151,6 +151,20 @@ trait SeriesCleanup { $this->compareExpectations($state); } + public function testCleanUpOrphanedFeedsWithUnlimitedRetention() { + Arsse::$conf->import([ + 'purgeFeeds' => null, + ]); + Arsse::$db->feedCleanup(); + $now = gmdate("Y-m-d H:i:s"); + $state = $this->primeExpectations($this->data, [ + 'arsse_feeds' => ["id","orphaned"] + ]); + $state['arsse_feeds']['rows'][0][1] = null; + $state['arsse_feeds']['rows'][2][1] = $now; + $this->compareExpectations($state); + } + public function testCleanUpOldArticlesWithStandardRetention() { Arsse::$db->articleCleanup(); $state = $this->primeExpectations($this->data, [ @@ -163,7 +177,9 @@ trait SeriesCleanup { } public function testCleanUpOldArticlesWithUnlimitedReadRetention() { - Arsse::$conf->purgeArticlesRead = ""; + Arsse::$conf->import([ + 'purgeArticlesRead' => null, + ]); Arsse::$db->articleCleanup(); $state = $this->primeExpectations($this->data, [ 'arsse_articles' => ["id"] @@ -175,7 +191,9 @@ trait SeriesCleanup { } public function testCleanUpOldArticlesWithUnlimitedUnreadRetention() { - Arsse::$conf->purgeArticlesUnread = ""; + Arsse::$conf->import([ + 'purgeArticlesUnread' => null, + ]); Arsse::$db->articleCleanup(); $state = $this->primeExpectations($this->data, [ 'arsse_articles' => ["id"] @@ -187,8 +205,10 @@ trait SeriesCleanup { } public function testCleanUpOldArticlesWithUnlimitedRetention() { - Arsse::$conf->purgeArticlesRead = ""; - Arsse::$conf->purgeArticlesUnread = ""; + Arsse::$conf->import([ + 'purgeArticlesRead' => null, + 'purgeArticlesUnread' => null, + ]); Arsse::$db->articleCleanup(); $state = $this->primeExpectations($this->data, [ 'arsse_articles' => ["id"] diff --git a/tests/cases/REST/NextCloudNews/TestV1_2.php b/tests/cases/REST/NextCloudNews/TestV1_2.php index 6e9b90af..f35e21e5 100644 --- a/tests/cases/REST/NextCloudNews/TestV1_2.php +++ b/tests/cases/REST/NextCloudNews/TestV1_2.php @@ -896,7 +896,7 @@ class TestV1_2 extends \JKingWeb\Arsse\Test\AbstractTest { } public function testQueryTheServerStatus() { - $interval = Service::interval(); + $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")); diff --git a/tests/cases/REST/TinyTinyRSS/TestAPI.php b/tests/cases/REST/TinyTinyRSS/TestAPI.php index d2422698..bf35a303 100644 --- a/tests/cases/REST/TinyTinyRSS/TestAPI.php +++ b/tests/cases/REST/TinyTinyRSS/TestAPI.php @@ -968,7 +968,7 @@ LONG_STRING; public function testRetrieveTheServerConfiguration() { $in = ['op' => "getConfig", 'sid' => "PriestsOfSyrinx"]; - $interval = Service::interval(); + $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")); diff --git a/tests/cases/Service/TestService.php b/tests/cases/Service/TestService.php index 4373c632..3c40da99 100644 --- a/tests/cases/Service/TestService.php +++ b/tests/cases/Service/TestService.php @@ -24,26 +24,6 @@ class TestService extends \JKingWeb\Arsse\Test\AbstractTest { $this->srv = new Service(); } - public function testComputeInterval() { - $in = [ - Arsse::$conf->serviceFrequency, - "PT2M", - "PT5M", - "P2M", - "5M", - "interval", - ]; - foreach ($in as $index => $spec) { - try { - $exp = new \DateInterval($spec); - } catch (\Exception $e) { - $exp = new \DateInterval("PT2M"); - } - Arsse::$conf->serviceFrequency = $spec; - $this->assertEquals($exp, Service::interval(), "Interval #$index '$spec' was not correctly calculated"); - } - } - public function testCheckIn() { $now = time(); $this->srv->checkIn(); @@ -54,7 +34,7 @@ class TestService extends \JKingWeb\Arsse\Test\AbstractTest { public function testReportHavingCheckedIn() { // the mock's metaGet() returns null by default $this->assertFalse(Service::hasCheckedIn()); - $interval = Service::interval(); + $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")); diff --git a/tests/lib/AbstractTest.php b/tests/lib/AbstractTest.php index 80bebe11..38142210 100644 --- a/tests/lib/AbstractTest.php +++ b/tests/lib/AbstractTest.php @@ -52,7 +52,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { 'dbMySQLPass' => "arsse_test", 'dbMySQLDb' => "arsse_test", ]; - Arsse::$conf = ($force ? null : Arsse::$conf) ?? (new Conf)->import($defaults)->import($conf); + Arsse::$conf = (($force ? null : Arsse::$conf) ?? (new Conf))->import($defaults)->import($conf); } public function assertException(string $msg = "", string $prefix = "", string $type = "Exception") { @@ -68,7 +68,7 @@ abstract class AbstractTest extends \PHPUnit\Framework\TestCase { $this->expectExceptionCode($code); } else { // expecting a standard PHP exception - $this->expectException(\Exception::class); + $this->expectException(\Throwable::class); } }