diff --git a/composer.json b/composer.json index 868e669..fb07712 100644 --- a/composer.json +++ b/composer.json @@ -1,45 +1,45 @@ { "name": "wikimedia/timestamp", "description": "Creation, parsing, and conversion of timestamps", "license": "GPL-2.0-or-later", "homepage": "https://www.mediawiki.org/wiki/Timestamp", "authors": [ { "name": "Tyler Romeo", "email": "tylerromeo@gmail.com" } ], "autoload": { "files": [ "src/defines.php" ], "psr-4": { "Wikimedia\\Timestamp\\": "src/" } }, "require": { "php": ">=7.2.9" }, "require-dev": { - "mediawiki/mediawiki-codesniffer": "35.0.0", + "mediawiki/mediawiki-codesniffer": "36.0.0", "mediawiki/minus-x": "1.1.1", "ockcyp/covers-validator": "1.3.3", "php-parallel-lint/php-console-highlighter": "0.5.0", - "php-parallel-lint/php-parallel-lint": "1.2.0", + "php-parallel-lint/php-parallel-lint": "1.3.0", "phpunit/phpunit": "^8.5" }, "scripts": { "test": [ "parallel-lint . --exclude vendor", "phpunit", "covers-validator", "phpcs -sp", "minus-x check ." ], "cover": "phpunit --coverage-html coverage", "fix": [ "minus-x fix .", "phpcbf" ] } } diff --git a/src/ConvertibleTimestamp.php b/src/ConvertibleTimestamp.php index f248803..d2ccce7 100644 --- a/src/ConvertibleTimestamp.php +++ b/src/ConvertibleTimestamp.php @@ -1,396 +1,396 @@ * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * http://www.gnu.org/copyleft/gpl.html * * @file * @author Tyler Romeo */ namespace Wikimedia\Timestamp; use DateInterval; use DateTime; use DateTimeZone; use Exception; /** * Library for creating, parsing, and converting timestamps. */ class ConvertibleTimestamp { /** * Standard gmdate() formats for the different timestamp types. * @var string[] */ private static $formats = [ TS_UNIX => 'U', TS_MW => 'YmdHis', TS_DB => 'Y-m-d H:i:s', TS_ISO_8601 => 'Y-m-d\TH:i:s\Z', TS_ISO_8601_BASIC => 'Ymd\THis\Z', // This shouldn't ever be used, but is included for completeness TS_EXIF => 'Y:m:d H:i:s', TS_RFC2822 => 'D, d M Y H:i:s', // Was 'd-M-y h.i.s A' . ' +00:00' before r51500 TS_ORACLE => 'd-m-Y H:i:s.u', // Formerly 'Y-m-d H:i:s' . ' GMT' TS_POSTGRES => 'Y-m-d H:i:s+00', TS_UNIX_MICRO => 'U.u', ]; /** * Regexes for setTimestamp(). Named capture groups correspond to format codes for * DateTime::createFromFormat(). Unnamed groups are ignored. * @var string[] */ private static $regexes = [ // 'TS_DB' => subset of TS_ISO_8601 (with no 'T') 'TS_MW' => '/^(?\d{4})(?\d\d)(?\d\d)(?\d\d)(?\d\d)(?\d\d)$/D', 'TS_ISO_8601' => '/^(?\d{4})-(?\d{2})-(?\d{2})[T ]' . '(?\d{2}):(?\d{2}):(?\d{2})(?:[.,](?\d{1,6}))?' . '(?Z|[+\-]\d{2}(?::?\d{2})?)?$/', 'TS_ISO_8601_BASIC' => '/^(?\d{4})(?\d{2})(?\d{2})T(?\d{2})(?\d{2})(?\d{2})(?:[.,](?\d{1,6}))?' . '(?Z|[+\-]\d{2}(?::?\d{2})?)?$/', 'TS_UNIX' => '/^(?-?\d{1,13})$/D', 'TS_UNIX_MICRO' => '/^(?-?\d{1,13})\.(?\d{1,6})$/D', 'TS_ORACLE' => '/^(?\d{2})-(?\d{2})-(?\d{4}) (?\d{2}):(?\d{2}):(?\d{2})\.(?\d{6})$/', // TS_POSTGRES is almost redundant to TS_ISO_8601 (with no 'T'), but accepts a space in place of // a `+` before the timezone. 'TS_POSTGRES' => '/^(?\d{4})-(?\d\d)-(?\d\d) (?\d\d):(?\d\d):(?\d\d)(?:\.(?\d{1,6}))?' . '(?[\+\- ]\d\d)$/', 'old TS_POSTGRES' => '/^(?\d{4})-(?\d\d)-(?\d\d) (?\d\d):(?\d\d):(?\d\d)(?:\.(?\d{1,6}))? GMT$/', 'TS_EXIF' => '/^(?\d{4}):(?\d\d):(?\d\d) (?\d\d):(?\d\d):(?\d\d)$/D', 'TS_RFC2822' => # Day of week '/^[ \t\r\n]*(?:(?[A-Z][a-z]{2}),[ \t\r\n]*)?' . # dd Mon yyyy '(?\d\d?)[ \t\r\n]+(?[A-Z][a-z]{2})[ \t\r\n]+(?\d{2,})' . # hh:mm:ss '[ \t\r\n]+(?\d\d)[ \t\r\n]*:[ \t\r\n]*(?\d\d)[ \t\r\n]*:[ \t\r\n]*(?\d\d)' . # zone, optional for hysterical raisins '(?:[ \t\r\n]+(?[+-]\d{4}|UT|GMT|[ECMP][SD]T|[A-IK-Za-ik-z]))?' . # optional trailing comment # See http://www.squid-cache.org/mail-archive/squid-users/200307/0122.html / r77171 '(?:[ \t\r\n]*;|$)/S', 'TS_RFC850' => '/^(?[A-Z][a-z]{5,8}), (?\d\d)-(?[A-Z][a-z]{2})-(?\d{2}) ' . '(?\d\d):(?\d\d):(?\d\d)' . # timezone optional for hysterical raisins. RFC just says "worldwide time zone abbreviations". # https://en.wikipedia.org/wiki/List_of_time_zone_abbreviations lists strings of up to 5 # uppercase letters. PHP 7.2's DateTimeZone::listAbbreviations() lists strings of up to 4 # letters. '(?: (?[+\-]\d{2}(?::?\d{2})?|[A-Z]{1,5}))?$/', 'asctime' => '/^(?[A-Z][a-z]{2}) (?[A-Z][a-z]{2}) +(?\d{1,2}) ' . '(?\d\d):(?\d\d):(?\d\d) (?\d{4})\s*$/', ]; /** * @var callback|null * @see setFakeTime() */ protected static $fakeTimeCallback = null; /** * Get the current time in the same form that PHP's built-in time() function uses. * * This is used by now() get setTimestamp( false ) instead of the built in time() function. * The output of this method can be overwritten for testing purposes by calling setFakeTime(). * * @return int UNIX epoch */ public static function time() { return static::$fakeTimeCallback ? call_user_func( static::$fakeTimeCallback ) : \time(); } /** * Set a fake time value or clock callback. * * @param callable|string|int|false $fakeTime a fixed time string, or an integer Unix time, or * a callback() returning an int representing a UNIX epoch, or false to disable fake time and * go back to real time. * * @return callable|null the previous fake time callback, if any. */ public static function setFakeTime( $fakeTime ) { if ( is_string( $fakeTime ) ) { $fakeTime = (int)static::convert( TS_UNIX, $fakeTime ); } if ( is_int( $fakeTime ) ) { - $fakeTime = function () use ( $fakeTime ) { + $fakeTime = static function () use ( $fakeTime ) { return $fakeTime; }; } $old = static::$fakeTimeCallback; static::$fakeTimeCallback = $fakeTime ? $fakeTime : null; return $old; } /** * The actual timestamp being wrapped (DateTime object). * @var DateTime */ public $timestamp; /** * Make a new timestamp and set it to the specified time, * or the current time if unspecified. * * @param bool|string|int|float|DateTime $timestamp Timestamp to set, or false for current time */ public function __construct( $timestamp = false ) { if ( $timestamp instanceof DateTime ) { $this->timestamp = $timestamp; } else { $this->setTimestamp( $timestamp ); } } /** * Set the timestamp to the specified time, or the current time if unspecified. * * Parse the given timestamp into either a DateTime object or a Unix timestamp, * and then store it. * * @param string|bool $ts Timestamp to store, or false for now * @throws TimestampException */ public function setTimestamp( $ts = false ) { $format = null; // We want to catch 0, '', null... but not date strings starting with a letter. if ( !$ts || $ts === "\0\0\0\0\0\0\0\0\0\0\0\0\0\0" ) { $name = 'null'; $strtime = (string)self::time(); $format = 'U'; } else { foreach ( self::$regexes as $name => $regex ) { if ( !preg_match( $regex, $ts, $m ) ) { continue; } // Apply RFC 2626 ยง 11.2 rules for fixing a 2-digit year. // For sanity we apply by year as written, without regard for // offset within the year or timezone of the input date. if ( isset( $m['y'] ) ) { $pivot = gmdate( 'Y', static::time() ) + 50; $m['Y'] = $pivot - ( $pivot % 100 ) + $m['y']; if ( $m['Y'] > $pivot ) { $m['Y'] -= 100; } unset( $m['y'] ); } // TS_POSTGRES's match for 'O' can begin with a space, which PHP doesn't accept if ( $name === 'TS_POSTGRES' && isset( $m['O'] ) && $m['O'][0] === ' ' ) { $m['O'][0] = '+'; } if ( $name === 'TS_RFC2822' ) { // RFC 2822 rules for two- and three-digit years if ( $m['Y'] < 1000 ) { $m['Y'] += $m['Y'] < 50 ? 2000 : 1900; } // TS_RFC2822 timezone fixups if ( isset( $m['O'] ) ) { // obs-zone value not recognized by PHP if ( $m['O'] === 'UT' ) { $m['O'] = 'UTC'; } // RFC 2822 says all these should be treated as +0000 due to an error in RFC 822 if ( strlen( $m['O'] ) === 1 ) { $m['O'] = '+0000'; } } } if ( $name === 'TS_UNIX_MICRO' && $m['U'] < 0 && $m['u'] > 0 ) { // createFromFormat()'s componentwise behavior is counterintuitive in this case, "-1.2" gets // interpreted as "-1 seconds + 200000 microseconds = -0.8 seconds" rather than as a decimal // "-1.2 seconds" like we want. So correct the values to match the componentwise // interpretation. $m['U']--; $m['u'] = 1000000 - str_pad( $m['u'], 6, '0' ); } $filtered = []; foreach ( $m as $k => $v ) { if ( !is_int( $k ) && $v !== '' ) { $filtered[$k] = $v; } } $format = implode( ' ', array_keys( $filtered ) ); $strtime = implode( ' ', array_values( $filtered ) ); break; } } if ( $format === null ) { throw new TimestampException( __METHOD__ . ": Invalid timestamp - $ts" ); } try { $final = DateTime::createFromFormat( "!$format", $strtime, new DateTimeZone( 'UTC' ) ); } catch ( Exception $e ) { throw new TimestampException( __METHOD__ . ': Invalid timestamp format.', $e->getCode(), $e ); } if ( $final === false ) { throw new TimestampException( __METHOD__ . ': Invalid timestamp format.' ); } $this->timestamp = $final; } /** * Converts any timestamp to the given string format. * This is identical to `( new ConvertibleTimestamp() )->getTimestamp()`, * except it returns false instead of throwing an exception. * * @param int $style Constant Output format for timestamp * @param string|int|float|bool|DateTime $ts Timestamp * @return string|false Formatted timestamp or false on failure */ public static function convert( $style, $ts ) { try { $ct = new static( $ts ); return $ct->getTimestamp( $style ); } catch ( TimestampException $e ) { return false; } } /** * Get the current time in the given format * * @param int $style Constant Output format for timestamp * @return string */ public static function now( $style = TS_MW ) { return static::convert( $style, false ); } /** * Get the timestamp represented by this object in a certain form. * * Convert the internal timestamp to the specified format and then * return it. * * @param int $style Constant Output format for timestamp * @throws TimestampException * @return string The formatted timestamp */ public function getTimestamp( $style = TS_UNIX ) { if ( !isset( self::$formats[$style] ) ) { throw new TimestampException( __METHOD__ . ': Illegal timestamp output type.' ); } // All our formats are in UTC, so make sure to use that timezone $timestamp = clone $this->timestamp; $timestamp->setTimezone( new DateTimeZone( 'UTC' ) ); if ( $style === TS_UNIX_MICRO ) { $seconds = $timestamp->format( 'U' ); $microseconds = $timestamp->format( 'u' ); if ( $seconds < 0 && $microseconds > 0 ) { // Adjust components to properly create a decimal number for TS_UNIX_MICRO and negative // timestamps. See the comment in setTimestamp() for details. $seconds++; $microseconds = 1000000 - $microseconds; } return sprintf( "%d.%06d", $seconds, $microseconds ); } $output = $timestamp->format( self::$formats[$style] ); if ( $style == TS_RFC2822 ) { $output .= ' GMT'; } if ( $style == TS_MW && strlen( $output ) !== 14 ) { throw new TimestampException( __METHOD__ . ': The timestamp cannot be represented in ' . 'the specified format' ); } return $output; } /** * @return string */ public function __toString() { return $this->getTimestamp(); } /** * Calculate the difference between two ConvertibleTimestamp objects. * * @param ConvertibleTimestamp $relativeTo Base time to calculate difference from * @return DateInterval|bool The DateInterval object representing the * difference between the two dates or false on failure */ public function diff( ConvertibleTimestamp $relativeTo ) { return $this->timestamp->diff( $relativeTo->timestamp ); } /** * Set the timezone of this timestamp to the specified timezone. * * @param string $timezone Timezone to set * @throws TimestampException */ public function setTimezone( $timezone ) { try { $this->timestamp->setTimezone( new DateTimeZone( $timezone ) ); } catch ( Exception $e ) { throw new TimestampException( __METHOD__ . ': Invalid timezone.', $e->getCode(), $e ); } } /** * Get the timezone of this timestamp. * * @return DateTimeZone The timezone */ public function getTimezone() { return $this->timestamp->getTimezone(); } /** * Format the timestamp in a given format. * * @param string $format Pattern to format in * @return string The formatted timestamp */ public function format( $format ) { return $this->timestamp->format( $format ); } } diff --git a/tests/ConvertibleTimestampTest.php b/tests/ConvertibleTimestampTest.php index f9edf53..fbfb102 100644 --- a/tests/ConvertibleTimestampTest.php +++ b/tests/ConvertibleTimestampTest.php @@ -1,520 +1,520 @@ * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * http://www.gnu.org/copyleft/gpl.html * * @file * @author Tyler Romeo */ namespace Wikimedia\Timestamp\Test; use Closure; use Wikimedia\Timestamp\ConvertibleTimestamp; class ConvertibleTimestampTest extends \PHPUnit\Framework\TestCase { protected function tearDown() : void { parent::tearDown(); ConvertibleTimestamp::setFakeTime( null ); } /** * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::__construct */ public function testConstructWithoutTimestamp() { $timestamp = new ConvertibleTimestamp(); $this->assertIsString( $timestamp->getTimestamp() ); $this->assertNotEmpty( $timestamp->getTimestamp() ); $this->assertNotFalse( strtotime( $timestamp->getTimestamp( TS_MW ) ) ); } /** * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::__construct */ public function testConstructWithDateTime() { $input = '1343761268'; $dt = new \DateTime( "@$input", new \DateTimeZone( 'GMT' ) ); $timestamp = new ConvertibleTimestamp( $dt ); $this->assertSame( $input, $timestamp->getTimestamp() ); } /** * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::__toString */ public function testToString() { // Value is equivalent to 20140731190108 $timestamp = new ConvertibleTimestamp( '1406833268' ); $this->assertSame( '1406833268', $timestamp->__toString() ); $timestamp = new ConvertibleTimestamp( '20140731190108' ); $this->assertSame( '1406833268', $timestamp->__toString() ); } public static function provideDiff() { return [ [ '1406833268', '1406833269', '00 00 00 01' ], [ '1406833268', '1406833329', '00 00 01 01' ], [ '1406833268', '1406836929', '00 01 01 01' ], [ '1406833268', '1406923329', '01 01 01 01' ], ]; } /** * @dataProvider provideDiff * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::diff */ public function testDiff( $timestamp1, $timestamp2, $expected ) { $timestamp1 = new ConvertibleTimestamp( $timestamp1 ); $timestamp2 = new ConvertibleTimestamp( $timestamp2 ); $diff = $timestamp1->diff( $timestamp2 ); $this->assertEquals( $expected, $diff->format( '%D %H %I %S' ) ); } /** * Parse valid timestamps and output in MW format. * * @dataProvider provideValidTimestamps * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::setTimestamp * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::getTimestamp */ public function testValidParse( $originalFormat, $original, $expectedFormat, $expected ) { $timestamp = new ConvertibleTimestamp( $original ); $this->assertEquals( $expected, $timestamp->getTimestamp( $expectedFormat ) ); } public static function provideParseOnly() { $s = " \r\n \t\t\n \r "; return [ // Systematically testing regexes 'TS_DB' => [ '2012-07-31 19:01:08', '1343761268.000000' ], 'TS_MW' => [ '20120731190108', '1343761268.000000' ], 'TS_ISO_8601' => [ '2012-07-31T19:01:08Z', '1343761268.000000' ], 'TS_ISO_8601, no Z' => [ '2012-07-31T19:01:08', '1343761268.000000' ], 'TS_ISO_8601, milliseconds' => [ '2012-07-31T19:01:08.123Z', '1343761268.123000' ], 'TS_ISO_8601, microseconds, no Z' => [ '2012-07-31T19:01:08.123456', '1343761268.123456' ], 'TS_ISO_8601, microseconds with comma' => [ '2012-07-31T19:01:08,123456', '1343761268.123456' ], 'TS_ISO_8601, timezone +0200' => [ '2012-07-31T21:01:08+0200', '1343761268.000000' ], 'TS_ISO_8601, timezone -02:00' => [ '2012-07-31T17:01:08-02:00', '1343761268.000000' ], 'TS_ISO_8601, timezone +04' => [ '2012-07-31T23:01:08.123+04', '1343761268.123000' ], 'TS_ISO_8601, no T' => [ '2012-07-31 19:01:08Z', '1343761268.000000' ], 'TS_ISO_8601, no T, no Z' => [ '2012-07-31 19:01:08', '1343761268.000000' ], 'TS_ISO_8601, no T, milliseconds' => [ '2012-07-31 19:01:08.123Z', '1343761268.123000' ], 'TS_ISO_8601, no T, timezone +0200' => [ '2012-07-31 21:01:08+0200', '1343761268.000000' ], 'TS_ISO_8601, no T, timezone -02:00' => [ '2012-07-31 17:01:08-02:00', '1343761268.000000' ], 'TS_ISO_8601, no T, timezone +04' => [ '2012-07-31 23:01:08.123+04', '1343761268.123000' ], 'TS_ISO_8601_BASIC' => [ '20120731T190108Z', '1343761268.000000' ], 'TS_ISO_8601_BASIC, no Z' => [ '20120731T190108', '1343761268.000000' ], 'TS_ISO_8601_BASIC, milliseconds' => [ '20120731T190108.123Z', '1343761268.123000' ], 'TS_ISO_8601_BASIC, microseconds, no Z' => [ '20120731T190108.123456', '1343761268.123456' ], 'TS_ISO_8601_BASIC, microseconds w/comma' => [ '20120731T190108,123456', '1343761268.123456' ], 'TS_ISO_8601_BASIC, timezone +0200' => [ '20120731T210108+0200', '1343761268.000000' ], 'TS_ISO_8601_BASIC, timezone -02:00' => [ '20120731T170108-02:00', '1343761268.000000' ], 'TS_ISO_8601_BASIC, timezone +04' => [ '20120731T230108.123+04', '1343761268.123000' ], 'TS_UNIX' => [ '1343761268', '1343761268.000000' ], 'TS_UNIX, negative' => [ '-1343761268', '-1343761268.000000' ], 'TS_UNIX_MICRO' => [ '1343761268.123456', '1343761268.123456' ], 'TS_UNIX_MICRO, lower precision' => [ '1343761268.123', '1343761268.123000' ], 'TS_UNIX_MICRO, negative' => [ '-1343761268.123456', '-1343761268.123456' ], 'TS_UNIX_MICRO, negative, low precision' => [ '-1343761268.123', '-1343761268.123000' ], 'TS_UNIX_MICRO, near-zero microseconds' => [ '1343761268.000006', '1343761268.000006' ], 'TS_ORACLE' => [ '31-07-2012 19:01:08.123456', '1343761268.123456' ], 'TS_POSTGRES' => [ '2012-07-31 19:01:08+00', '1343761268.000000' ], 'TS_POSTGRES, milliseconds' => [ '2012-07-31 19:01:08.123+00', '1343761268.123000' ], 'TS_POSTGRES, microseconds' => [ '2012-07-31 19:01:08.123456+00', '1343761268.123456' ], 'TS_POSTGRES, timezone +02' => [ '2012-07-31 21:01:08+02', '1343761268.000000' ], 'TS_POSTGRES, timezone -02' => [ '2012-07-31 17:01:08-02', '1343761268.000000' ], 'TS_POSTGRES, timezone 02' => [ '2012-07-31 21:01:08 02', '1343761268.000000' ], 'old TS_POSTGRES' => [ '2012-07-31 19:01:08 GMT', '1343761268.000000' ], 'old TS_POSTGRES, milliseconds' => [ '2012-07-31 19:01:08.123 GMT', '1343761268.123000' ], 'old TS_POSTGRES, microseconds' => [ '2012-07-31 19:01:08.123456 GMT', '1343761268.123456' ], 'TS_EXIF' => [ '2012-07-31 19:01:08', '1343761268.000000' ], 'TS_RFC2822' => [ 'Tue, 31 Jul 2012 19:01:08 GMT', '1343761268.000000' ], 'TS_RFC2822, odd spacing' => [ "{$s}Tue,{$s}31{$s}Jul{$s}2012{$s}19{$s}:{$s}01{$s}:{$s}08{$s}GMT", '1343761268.000000' ], 'TS_RFC2822, minimal spacing' => [ 'Tue,31 Jul 2012 19:01:08 GMT', '1343761268.000000' ], 'TS_RFC2822, no weekday' => [ '31 Jul 2012 19:01:08 GMT', '1343761268.000000' ], 'TS_RFC2822, single-digit day' => [ 'Tue, 1 Jul 2012 19:01:08 GMT', '1341342068.000000' ], 'TS_RFC2822, year "12" => 2012' => [ 'Tue, 31 Jul 12 19:01:08 GMT', '1343761268.000000' ], 'TS_RFC2822, year "50" => 1950' => [ 'Tue, 31 Jul 50 19:01:08 GMT', '-612766732.000000' ], 'TS_RFC2822, year "112" => 2012' => [ 'Tue, 31 Jul 112 19:01:08 GMT', '1343761268.000000' ], 'TS_RFC2822, missing timezone' => [ 'Tue, 31 Jul 2012 19:01:08', '1343761268.000000' ], 'TS_RFC2822, timezone UT' => [ 'Tue, 31 Jul 2012 19:01:08 UT', '1343761268.000000' ], 'TS_RFC2822, timezone +0200' => [ 'Tue, 31 Jul 2012 21:01:08 +0200', '1343761268.000000' ], 'TS_RFC2822, timezone -0200' => [ 'Tue, 31 Jul 2012 17:01:08 -0200', '1343761268.000000' ], 'TS_RFC2822, timezone EDT' => [ 'Tue, 31 Jul 2012 15:01:08 EDT', '1343761268.000000' ], 'TS_RFC2822, timezone A (ignored)' => [ 'Tue, 31 Jul 2012 19:01:08 A', '1343761268.000000' ], 'TS_RFC2822, timezone n (ignored)' => [ 'Tue, 31 Jul 2012 19:01:08 n', '1343761268.000000' ], 'TS_RFC2822, trailing comment' => [ 'Tue, 31 Jul 2012 19:01:08 GMT; a comment', '1343761268.000000' ], 'TS_RFC2822, trailing comment with space' => [ "Tue, 31 Jul 2012 19:01:08 GMT{$s}; a comment", '1343761268.000000' ], 'TS_RFC850' => [ 'Tuesday, 31-Jul-12 19:01:08 UTC', '1343761268.000000' ], 'TS_RFC850, no timezone' => [ 'Tuesday, 31-Jul-12 19:01:08', '1343761268.000000' ], 'TS_RFC850, timezone +02' => [ 'Tuesday, 31-Jul-12 21:01:08 +02', '1343761268.000000' ], 'TS_RFC850, timezone +0200' => [ 'Tuesday, 31-Jul-12 21:01:08 +0200', '1343761268.000000' ], 'TS_RFC850, timezone +02:00' => [ 'Tuesday, 31-Jul-12 21:01:08 +02:00', '1343761268.000000' ], 'TS_RFC850, timezone -02' => [ 'Tuesday, 31-Jul-12 17:01:08 -02', '1343761268.000000' ], 'TS_RFC850, timezone -0200' => [ 'Tuesday, 31-Jul-12 17:01:08 -0200', '1343761268.000000' ], 'TS_RFC850, timezone -02:00' => [ 'Tuesday, 31-Jul-12 17:01:08 -02:00', '1343761268.000000' ], 'TS_RFC850, timezone EDT' => [ 'Tuesday, 31-Jul-12 15:01:08 EDT', '1343761268.000000' ], 'TS_RFC850, timezone X' => [ 'Tuesday, 31-Jul-12 08:01:08 X', '1343761268.000000' ], 'TS_RFC850, timezone CEST' => [ 'Tuesday, 31-Jul-12 21:01:08 CEST', '1343761268.000000' ], 'asctime' => [ 'Tue Jul 31 19:01:08 2012', '1343761268.000000' ], 'asctime, one-digit day' => [ 'Tue Jul 1 19:01:08 2012', '1341342068.000000' ], 'asctime, one-digit day without space' => [ 'Tue Jul 1 19:01:08 2012', '1341342068.000000' ], 'asctime, with newline' => [ "Tue Jul 31 19:01:08 2012\n", '1343761268.000000' ], // Testing timezone handling 'TS_POSTGRES w/zone => TS_MW' => [ '2012-07-31 21:01:08+02', '20120731190108', TS_MW ], 'TS_RFC2822 w/zone => TS_MW' => [ 'Tue, 31 Jul 2012 15:01:08 EDT', '20120731190108', TS_MW ], 'TS_RFC850 w/zone => TS_MW' => [ 'Tuesday, 31-Jul-12 17:01:08 -02:00', '20120731190108', TS_MW ], ]; } /** * @dataProvider provideParseOnly * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::setTimestamp */ public function testValidParseOnly( $original, $expected, $format = TS_UNIX_MICRO ) { // Parsing of the 2-digit year in RFC 850 format is tested more extensively below. // For this test, just make sure it doesn't break in 2062. - ConvertibleTimestamp::setFakeTime( function () { + ConvertibleTimestamp::setFakeTime( static function () { return 1570123766; } ); $timestamp = new ConvertibleTimestamp( $original ); $this->assertEquals( $expected, $timestamp->getTimestamp( $format ) ); } public static function provide2DigitYearHandling() { return [ '00 in 2019' => [ '2019', '00', '2000' ], '69 in 2019' => [ '2019', '69', '2069' ], '70 in 2019' => [ '2019', '70', '1970' ], '70 in 2020' => [ '2020', '70', '2070' ], '71 in 2020' => [ '2020', '71', '1971' ], '19 in 2069' => [ '2069', '19', '2119' ], '20 in 2069' => [ '2069', '20', '2020' ], '20 in 2070' => [ '2070', '20', '2120' ], '21 in 2070' => [ '2070', '21', '2021' ], '12 in 1820' => [ '1820', '12', '1812' ], '12 in 1890' => [ '1890', '12', '1912' ], '12 in 2320' => [ '2320', '12', '2312' ], '12 in 2390' => [ '2390', '12', '2412' ], ]; } /** * @dataProvider provide2DigitYearHandling * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::setTimestamp */ public function test2DigitYearHandling( $thisYear, $inYear, $outYear ) { $tz = new \DateTimeZone( 'UTC' ); // We test with an "now" at the beginning and end of the year $nowTimes = [ "$thisYear-01-01 00:00:00", "$thisYear-12-31 23:59:59" ]; // Test a timestamp in the middle of the year, plus for sanity checking // a timestamp that's actually in the adjacent UTC-years. // We need to get the day-of-week right, or else PHP adjusts the date to make it match. $timestamps = []; $day = \DateTime::createFromFormat( 'Y-m-d', "$outYear-07-31", $tz )->format( 'l' ); $timestamps[] = [ "$day, 31-Jul-$inYear 00:00:00 +00", $outYear ]; $day = \DateTime::createFromFormat( 'Y-m-d', "$outYear-12-31", $tz )->format( 'l' ); $timestamps[] = [ "$day, 31-Dec-$inYear 23:59:59 -01", $outYear + 1 ]; $day = \DateTime::createFromFormat( 'Y-m-d', "$outYear-01-01", $tz )->format( 'l' ); $timestamps[] = [ "$day, 01-Jan-$inYear 00:00:00 +01", $outYear - 1 ]; foreach ( $nowTimes as $nowTime ) { $now = \DateTime::createFromFormat( 'Y-m-d H:i:s', $nowTime, $tz )->getTimestamp(); $this->assertSame( $nowTime, gmdate( 'Y-m-d H:i:s', $now ), 'sanity check' ); - ConvertibleTimestamp::setFakeTime( function () use ( $now ) { + ConvertibleTimestamp::setFakeTime( static function () use ( $now ) { return $now; } ); foreach ( $timestamps as list( $ts, $expectYear ) ) { $timestamp = new ConvertibleTimestamp( $ts ); $timestamp->setTimezone( 'UTC' ); $this->assertEquals( $expectYear, $timestamp->format( 'Y' ), "$ts at $nowTime UTC" ); } } } /** * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::setTimestamp */ public function testValidParseZero() { $now = time(); $timestamp = new ConvertibleTimestamp( 0 ); $this->assertEqualsWithDelta( $now, $timestamp->getTimestamp( TS_UNIX ), 10.0, 'now' ); } /** * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::now */ public function testNow() { $this->assertEqualsWithDelta( time(), ConvertibleTimestamp::now( TS_UNIX ), 10.0, 'now' ); } /** * Parse invalid timestamps. * * @dataProvider provideInvalidTimestamps * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::setTimestamp */ public function testInvalidParse( $input ) { $this->expectException( \Wikimedia\Timestamp\TimestampException::class ); new ConvertibleTimestamp( $input ); } /** * Output valid timestamps in different formats. * * @dataProvider provideValidTimestamps * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::getTimestamp */ public function testValidFormats( $expectedFormat, $expected, $originalFormat, $original ) { $timestamp = new ConvertibleTimestamp( $original ); $this->assertEquals( $expected, (string)$timestamp->getTimestamp( $expectedFormat ) ); } /** * @dataProvider provideValidTimestamps * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::convert */ public function testConvert( $expectedFormat, $expected, $originalFormat, $original ) { $this->assertSame( $expected, ConvertibleTimestamp::convert( $expectedFormat, $original ) ); } /** * Format an invalid timestamp. * * @dataProvider provideInvalidTimestamps * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::convert */ public function testConvertInvalid( $input ) { $this->assertSame( false, ConvertibleTimestamp::convert( TS_UNIX, $input ) ); } /** * Test an out-of-range timestamp. * * @dataProvider provideOutOfRangeTimestamps * @covers \Wikimedia\Timestamp\ConvertibleTimestamp */ public function testOutOfRangeTimestamps( $format, $input ) { $timestamp = new ConvertibleTimestamp( $input ); $this->expectException( \Wikimedia\Timestamp\TimestampException::class ); $timestamp->getTimestamp( $format ); } public static function provideInvalidFormats() { return [ [ 'Not a format' ], [ 98 ], 'TS_UNIX_MICRO, excess precision' => [ '1343761268.123456789' ], ]; } /** * @dataProvider provideInvalidFormats * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::getTimestamp */ public function testInvalidFormat( $format ) { $timestamp = new ConvertibleTimestamp( '1343761268' ); $this->expectException( \Wikimedia\Timestamp\TimestampException::class ); $timestamp->getTimestamp( $format ); } /** * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::setTimezone */ public function testSetTimezone() { $timestamp = new ConvertibleTimestamp( '2012-07-31 19:01:08' ); $this->assertSame( '2012-07-31 19:01:08+0000', $timestamp->format( 'Y-m-d H:i:sO' ) ); $timestamp->setTimezone( 'America/New_York' ); $this->assertSame( '2012-07-31 15:01:08-0400', $timestamp->format( 'Y-m-d H:i:sO' ) ); $timestamp = new ConvertibleTimestamp( 'Tue, 31 Jul 2012 15:01:08 EDT' ); $this->assertSame( '2012-07-31 15:01:08-0400', $timestamp->format( 'Y-m-d H:i:sO' ) ); $timestamp->setTimezone( 'UTC' ); $this->assertSame( '2012-07-31 19:01:08+0000', $timestamp->format( 'Y-m-d H:i:sO' ) ); } /** * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::setTimezone */ public function testSetTimezoneInvalid() { $timestamp = new ConvertibleTimestamp( 0 ); $this->expectException( \Wikimedia\Timestamp\TimestampException::class ); $timestamp->setTimezone( 'Invalid' ); } /** * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::getTimezone */ public function testGetTimezone() { $timestamp = new ConvertibleTimestamp( 0 ); $this->assertInstanceOf( \DateTimeZone::class, $timestamp->getTimezone() ); } /** * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::format */ public function testFormat() { $timestamp = new ConvertibleTimestamp( '1343761268' ); $this->assertSame( '1343761268', $timestamp->format( 'U' ) ); $this->assertSame( '20120731190108', $timestamp->format( 'YmdHis' ) ); } /** * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::setFakeTime * @covers \Wikimedia\Timestamp\ConvertibleTimestamp::time */ public function testFakeTime() { // fake clock ticks up $fakeTime = (int)ConvertibleTimestamp::convert( TS_UNIX, '20010101000000' ); $fakeClock = $fakeTime; - ConvertibleTimestamp::setFakeTime( function () use ( &$fakeClock ) { + ConvertibleTimestamp::setFakeTime( static function () use ( &$fakeClock ) { return $fakeClock++; } ); $this->assertSame( $fakeTime, ConvertibleTimestamp::time() ); $this->assertSame( '20010101000001', ConvertibleTimestamp::now() ); $this->assertSame( '20010101000002', ConvertibleTimestamp::convert( TS_MW, false ) ); $this->assertSame( '20010101000003', ConvertibleTimestamp::now() ); $this->assertSame( $fakeTime + 4, ConvertibleTimestamp::time() ); // fake time stays put $old = ConvertibleTimestamp::setFakeTime( '20200202112233' ); $this->assertTrue( is_callable( $old ) ); $fakeTime = (int)ConvertibleTimestamp::convert( TS_UNIX, '20200202112233' ); $this->assertSame( $fakeTime, ConvertibleTimestamp::time() ); $this->assertSame( '20200202112233', ConvertibleTimestamp::now() ); $this->assertSame( '20200202112233', ConvertibleTimestamp::convert( TS_MW, false ) ); $this->assertSame( '20200202112233', ConvertibleTimestamp::now() ); // no more fake time $old = ConvertibleTimestamp::setFakeTime( false ); $this->assertInstanceOf( Closure::class, $old ); $this->assertSame( '20200202112233', ConvertibleTimestamp::convert( TS_MW, $old() ) ); $this->assertNotSame( '20200202112233', ConvertibleTimestamp::now() ); $this->assertNotSame( $fakeTime, ConvertibleTimestamp::time() ); } /** * Returns a list of valid timestamps in the format: * [ type, timestamp_of_type, timestamp_in_MW ] */ public static function provideValidTimestamps() { return [ // Formats supported in both directions [ TS_UNIX, '1343761268', TS_MW, '20120731190108' ], [ TS_UNIX_MICRO, '1343761268.000000', TS_MW, '20120731190108' ], [ TS_UNIX_MICRO, '1343761268.123456', TS_ORACLE, '31-07-2012 19:01:08.123456' ], [ TS_MW, '20120731190108', TS_MW, '20120731190108' ], [ TS_DB, '2012-07-31 19:01:08', TS_MW, '20120731190108' ], [ TS_ISO_8601, '2012-07-31T19:01:08Z', TS_MW, '20120731190108' ], [ TS_ISO_8601_BASIC, '20120731T190108Z', TS_MW, '20120731190108' ], [ TS_EXIF, '2012:07:31 19:01:08', TS_MW, '20120731190108' ], [ TS_RFC2822, 'Tue, 31 Jul 2012 19:01:08 GMT', TS_MW, '20120731190108' ], [ TS_ORACLE, '31-07-2012 19:01:08.000000', TS_MW, '20120731190108' ], [ TS_ORACLE, '31-07-2012 19:01:08.123456', TS_UNIX_MICRO, '1343761268.123456' ], [ TS_POSTGRES, '2012-07-31 19:01:08+00', TS_MW, '20120731190108' ], // Some extremes and weird values [ TS_ISO_8601, '9999-12-31T23:59:59Z', TS_MW, '99991231235959' ], [ TS_UNIX, '-62135596801', TS_MW, '00001231235959' ], [ TS_UNIX_MICRO, '-1.100000', TS_ORACLE, '31-12-1969 23:59:58.900000' ], ]; } /** * List of invalid timestamps */ public static function provideInvalidTimestamps() { return [ // Not matching any known patterns // (throws from main 'else' branch in setTimestamp) [ 'Not a timestamp' ], [ '1971:01:01 06:19:385' ], // Invalid values for known patterns // (throws from DateTime construction) [ 'Zed, 40 Mud 2012 99:99:99 GMT' ], // Odd cases formerly accepted [ '2019-05-22T12:00:00.....1257' ], [ '2019-05-22T12:00:001257' ], [ '2019-05-22T12:00:00.....' ], [ '20190522T120000.....1257' ], [ '20190522T1200001257' ], [ '20190522T120000.....' ], [ '2019-05-22 12:00:00....1257-04' ], [ '2019-05-22 12:00:001257-04' ], [ '2019-05-22 12:00:00...-04' ], [ '2019-05-22 12:00:00....1257 GMT' ], [ '2019-05-22 12:00:001257 GMT' ], [ '2019-05-22 12:00:00... GMT' ], [ 'Wed, 22 May 2019 12:00:00 A potato' ], [ 'Wed, 22 May 2019 12:00:00 + 2 days' ], [ 'Wed, 22 May 2019 12:00:00 Monday' ], [ 'Wednesday, 22-May-19 12:00:00 A potato' ], [ 'Wednesday, 22-May-19 12:00:00 + 2 days' ], [ 'Wednesday, 22-May-19 12:00:00 Monday' ], [ 'Wed May 22 12:00:00 2019 A potato' ], [ 'Wed May 22 12:00:00 2019 + 2 days' ], [ 'Wed May 22 12:00:00 2019 Monday' ], [ 'Tue Jul 31 19:01:08 2012 UTC' ], ]; } /** * Returns a list of out of range timestamps in the format: * [ type, timestamp_of_type ] */ public static function provideOutOfRangeTimestamps() { // Various formats return [ // -0001-12-31T23:59:59Z [ TS_MW, '-62167219201' ], // 10000-01-01T00:00:00Z [ TS_MW, '253402300800' ], ]; } }