From 49324174751b3d2e6d1f49da9d2d4b0b6c15722e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milan=20Mat=C4=9Bj=C4=8Dek?= Date: Tue, 13 Feb 2024 10:27:19 +0100 Subject: [PATCH] Pre --- README.md | 2 +- composer.json | 4 +- examples/index.php | 2 +- phpstan.neon | 5 +- src/Download/SourceData.php | 20 ++ src/Download/SourceDownload.php | 88 +++++++ src/Download/SourceDownloadInterface.php | 17 ++ src/Driver/Cnb/Day.php | 62 +++-- src/Driver/Driver.php | 113 -------- src/Driver/DriverAccessor.php | 10 - src/Driver/DriverBuilder.php | 18 -- src/Driver/DriverBuilderFactory.php | 76 ------ src/Driver/Ecb/Day.php | 56 ++-- src/Driver/RB/Day.php | 66 +++-- src/Driver/RB/DayBuy.php | 5 +- src/Driver/RB/DayCenter.php | 5 +- src/Driver/RB/DaySell.php | 5 +- src/Driver/Source.php | 24 ++ src/Exchange.php | 119 +++------ src/ExchangeFactory.php | 76 +++--- src/ExchangeFactoryInterface.php | 15 ++ src/RatingList/CacheEntity.php | 21 +- src/RatingList/RatingList.php | 107 +++----- src/RatingList/RatingListBuilder.php | 14 - src/RatingList/RatingListCache.php | 138 +++------- src/RatingList/RatingListInterface.php | 15 +- src/Utils.php | 63 +++++ tests/src/Caching/CacheTest.php | 53 ---- tests/src/Currency/PropertyTest.php | 25 ++ tests/src/Driver/Cnb/DayTest.php | 33 --- tests/src/Driver/Ecb/DayTest.php | 36 --- tests/src/Driver/RB/DayTest.php | 68 ----- tests/src/E2E/ExchangeTest.php | 26 -- tests/src/E2E/SourceDownloadTest.php | 255 +++++++++++++++++++ tests/src/ExchangeFactoryTest.php | 27 ++ tests/src/ExchangeTest.php | 143 +++++++---- tests/src/RatingList/RatingListCacheTest.php | 215 ++++++++++++++++ tests/src/RatingList/RatingListTest.php | 75 ++---- tests/src/TimestampTimeZoneTest.php | 8 +- tests/src/UtilsTest.php | 111 +++++++- 40 files changed, 1269 insertions(+), 952 deletions(-) create mode 100644 src/Download/SourceData.php create mode 100644 src/Download/SourceDownload.php create mode 100644 src/Download/SourceDownloadInterface.php delete mode 100644 src/Driver/Driver.php delete mode 100644 src/Driver/DriverAccessor.php delete mode 100644 src/Driver/DriverBuilder.php delete mode 100644 src/Driver/DriverBuilderFactory.php create mode 100644 src/Driver/Source.php create mode 100644 src/ExchangeFactoryInterface.php delete mode 100644 src/RatingList/RatingListBuilder.php delete mode 100644 tests/src/Caching/CacheTest.php create mode 100644 tests/src/Currency/PropertyTest.php delete mode 100644 tests/src/Driver/Cnb/DayTest.php delete mode 100644 tests/src/Driver/Ecb/DayTest.php delete mode 100644 tests/src/Driver/RB/DayTest.php delete mode 100644 tests/src/E2E/ExchangeTest.php create mode 100644 tests/src/E2E/SourceDownloadTest.php create mode 100644 tests/src/ExchangeFactoryTest.php create mode 100644 tests/src/RatingList/RatingListCacheTest.php diff --git a/README.md b/README.md index a5c76cd..5a46079 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ $exchangeFactory = new Exchange\ExchangeFactory( 'USD', 'eur', // lower case will be changed to upper case ], - cacheFactory: $cacheFactory + cache: $cacheFactory ); $exchange = $exchangeFactory->create(); diff --git a/composer.json b/composer.json index 85f20fb..b96dc37 100644 --- a/composer.json +++ b/composer.json @@ -15,16 +15,16 @@ "php": ">=8.0", "h4kuna/critical-cache": "^v0.1.3", "h4kuna/data-type": "^v3.0.7", - "h4kuna/serialize-polyfill": "^0.2.2", "malkusch/lock": "^2.2", "psr/http-client": "^1.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.0 || ^2.0" }, "require-dev": { - "guzzlehttp/psr7": "^2.4", "guzzlehttp/guzzle": "^7.5", + "guzzlehttp/psr7": "^2.4", "h4kuna/dir": "^0.1.2", + "mockery/mockery": "^1.6", "nette/caching": "^3.2", "nette/tester": "^2.5", "phpstan/phpstan": "^1.10", diff --git a/examples/index.php b/examples/index.php index 6e5bf4d..6dc7fa7 100644 --- a/examples/index.php +++ b/examples/index.php @@ -16,7 +16,7 @@ 'USD', 'eur', // lower case will be changed to upper case ], - cacheFactory: $cacheFactory + cache: $cacheFactory ); $exchange = $exchangeFactory->create(); diff --git a/phpstan.neon b/phpstan.neon index b8fffd2..6347554 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -7,10 +7,7 @@ parameters: - tests/bootstrap.php checkGenericClassInNonGenericObjectType: false ignoreErrors: - - - message: "#^Expression \"\\$ratingList\\['QWE'\\]\" on a separate line does not do anything\\.$#" - count: 1 - path: tests/src/RatingList/RatingListTest.php + - "#^Call to an undefined method Mockery\\.*#" includes: - vendor/phpstan/phpstan-strict-rules/rules.neon diff --git a/src/Download/SourceData.php b/src/Download/SourceData.php new file mode 100644 index 0000000..6e6bdb7 --- /dev/null +++ b/src/Download/SourceData.php @@ -0,0 +1,20 @@ + $properties + */ + public function __construct( + public DateTimeImmutable $date, + public string $refresh, + public iterable $properties, + ) + { + } + +} diff --git a/src/Download/SourceDownload.php b/src/Download/SourceDownload.php new file mode 100644 index 0000000..8ba0aba --- /dev/null +++ b/src/Download/SourceDownload.php @@ -0,0 +1,88 @@ + + */ + private array $cache = []; + + + /** + * @param array $allowedCurrencies + */ + public function __construct( + private ClientInterface $client, + private RequestFactoryInterface $requestFactory, + private array $allowedCurrencies = [], + ) + { + } + + + private static function makeKey(Source $sourceExchange, ?DateTimeImmutable $date): string + { + $rf = new ReflectionClass($sourceExchange); + do { + $class = $rf->getName(); + $rf = $rf->getParentClass(); + } while ($rf !== false); + + if ($date === null) { + $date = new DateTimeImmutable('now', $sourceExchange->getTimeZone()); + } + + return $class . '.' . $date->format('Y-m-d'); + } + + + public function execute(Source $sourceExchange, ?DateTimeInterface $date): RatingList + { + $date = Utils::toImmutable($date, $sourceExchange->getTimeZone()); + $key = self::makeKey($sourceExchange, $date); + + $sourceData = $this->cache[$key] + ?? $this->cache[$key] = $sourceExchange->createSourceData( + $this->client->sendRequest( + $this->createRequest($sourceExchange, $date) + ) + ); + + $expire = $date === null ? new DateTime($sourceData->refresh . sprintf(', +%s seconds', Utils::CacheMinutes), $sourceExchange->getTimeZone()) : null; + + $properties = []; + foreach ($sourceData->properties as $item) { + $property = $sourceExchange->createProperty($item); + if ($property->rate === 0.0 || ($this->allowedCurrencies !== [] && isset($this->allowedCurrencies[$property->code]) === false)) { + continue; + } + + $properties[$property->code] = $property; + } + + return new RatingList($sourceData->date, $date, $expire, $properties); + } + + + private function createRequest(Source $sourceExchange, ?DateTimeInterface $date): RequestInterface + { + $request = $this->requestFactory->createRequest('GET', $sourceExchange->makeUrl($date)); + $request->withHeader('X-Powered-By', 'h4kuna/exchange'); + + return $request; + } + +} diff --git a/src/Download/SourceDownloadInterface.php b/src/Download/SourceDownloadInterface.php new file mode 100644 index 0000000..007e188 --- /dev/null +++ b/src/Download/SourceDownloadInterface.php @@ -0,0 +1,17 @@ + - */ -class Day extends Exchange\Driver\Driver +class Day implements Exchange\Driver\Source { - // private const URL_DAY_OTHER = 'http://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_ostatnich_men/kurzy.txt'; - public static string $url = 'https://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.txt'; + private DateTimeZone $timeZone; + public function __construct( - ClientInterface $client, - RequestFactoryInterface $requestFactory, - string $timeZone = 'Europe/Prague', - string $refresh = 'today 14:45:00', + string|DateTimeZone $timeZone = 'Europe/Prague', + private string $refresh = 'today 14:30:00', ) { - parent::__construct($client, $requestFactory, $timeZone, $refresh); + $this->timeZone = Utils::createTimeZone($timeZone); + } + + + public function getTimeZone(): DateTimeZone + { + return $this->timeZone; } - protected function createList(ResponseInterface $response): iterable + public function makeUrl(?DateTimeInterface $date): string + { + $url = self::$url; + + if ($date === null) { + return $url; + } + + return "$url?" . http_build_query([ + 'date' => $date->format('d.m.Y'), + ]); + } + + + public function createSourceData(ResponseInterface $response): SourceData { $data = $response->getBody()->getContents(); - $list = explode("\n", Exchange\Utils::stroke2point($data)); + $list = explode("\n", Utils::stroke2point($data)); $list[1] = 'Česká Republika|koruna|1|CZK|1'; - $this->setDate('!d.m.Y', explode(' ', $list[0])[0]); + $date = Utils::createFromFormat('!d.m.Y', explode(' ', $list[0])[0], $this->timeZone); unset($list[0]); - return $list; + return new SourceData($date, $this->refresh, $list); } - protected function createProperty($row): Property + public function createProperty(mixed $row): Property { + assert(is_string($row)); $currency = explode('|', $row); return new Property( @@ -54,12 +72,4 @@ protected function createProperty($row): Property ); } - - protected function prepareUrl(?\DateTimeInterface $date): string - { - $url = self::$url; - - return $date === null ? $url : "$url?date=" . urlencode($date->format('d.m.Y')); - } - } diff --git a/src/Driver/Driver.php b/src/Driver/Driver.php deleted file mode 100644 index 3cb52d3..0000000 --- a/src/Driver/Driver.php +++ /dev/null @@ -1,113 +0,0 @@ - $allowedCurrencies - * @return Generator - * - * @throws ClientExceptionInterface - */ - public function initRequest(?DateTimeInterface $date, array $allowedCurrencies): Generator - { - $content = $this->client->sendRequest($this->createRequest($date)); - - foreach ($this->createList($content) as $data) { - $property = $this->createProperty($data); - - if ($property->rate === 0.0 || ($allowedCurrencies !== [] && isset($allowedCurrencies[$property->code]) === false)) { - continue; - } - - yield $property; - } - - return []; - } - - - public function getDate(): DateTimeImmutable - { - return $this->date; - } - - - public function getRefresh(): DateTime - { - return new DateTime($this->refresh, new DateTimeZone($this->timeZone)); - } - - - protected function setDate(string $format, string $value): void - { - $date = DateTimeImmutable::createFromFormat($format, $value, new DateTimeZone($this->timeZone)); - if ($date === false) { - throw new Exchange\Exceptions\InvalidStateException(sprintf('Can not create DateTime object from source "%s" with format "%s".', $value, $format)); - } - $this->date = $date; - } - - - /** - * @return iterable - */ - abstract protected function createList(ResponseInterface $response): iterable; - - - /** - * @param Source $row - * @return T - */ - abstract protected function createProperty($row); - - - abstract protected function prepareUrl(?DateTimeInterface $date): string; - - - private function createRequest(?DateTimeInterface $date): RequestInterface - { - if ($date !== null && $date->getTimezone()->getName() !== $this->timeZone) { - $tmp = new DateTime('now', new DateTimeZone($this->timeZone)); - $tmp->setTimestamp($date->getTimestamp()); - $date = $tmp; - } - - $request = $this->requestFactory->createRequest('GET', $this->prepareUrl($date)); - $request->withHeader('X-Powered-By', 'h4kuna/exchange'); - - return $request; - } - -} diff --git a/src/Driver/DriverAccessor.php b/src/Driver/DriverAccessor.php deleted file mode 100644 index b7f7dab..0000000 --- a/src/Driver/DriverAccessor.php +++ /dev/null @@ -1,10 +0,0 @@ - - */ -final class DriverBuilder extends LazyBuilder implements DriverAccessor -{ - - public function get(string|int $key): Driver - { - return parent::get($key); - } - -} diff --git a/src/Driver/DriverBuilderFactory.php b/src/Driver/DriverBuilderFactory.php deleted file mode 100644 index 357782b..0000000 --- a/src/Driver/DriverBuilderFactory.php +++ /dev/null @@ -1,76 +0,0 @@ - fn () => $this->createCnb(), - Ecb\Day::class => fn () => $this->createEcb(), - RB\DayCenter::class => fn () => $this->createRB(RB\DayCenter::class), - RB\DayBuy::class => fn () => $this->createRB(RB\DayBuy::class), - RB\DaySell::class => fn () => $this->createRB(RB\DaySell::class), - ]); - } - - - protected function createCnb(): Driver - { - return new Cnb\Day($this->getClient(), $this->getRequestFactory()); - } - - - protected function createEcb(): Driver - { - return new Ecb\Day($this->getClient(), $this->getRequestFactory()); - } - - - /** - * @param class-string $class - */ - protected function createRB(string $class): Driver - { - return new $class($this->getClient(), $this->getRequestFactory()); - } - - - protected function getClient(): ClientInterface - { - if ($this->client === null) { - MissingDependencyException::guzzleClient(); - $this->client = new Client(); - } - - return $this->client; - } - - - protected function getRequestFactory(): RequestFactoryInterface - { - if ($this->requestFactory === null) { - MissingDependencyException::guzzleFactory(); - $this->requestFactory = new HttpFactory(); - } - - return $this->requestFactory; - } - -} diff --git a/src/Driver/Ecb/Day.php b/src/Driver/Ecb/Day.php index d6ca628..4350cf0 100644 --- a/src/Driver/Ecb/Day.php +++ b/src/Driver/Ecb/Day.php @@ -3,37 +3,47 @@ namespace h4kuna\Exchange\Driver\Ecb; use DateTimeInterface; +use DateTimeZone; use h4kuna\Exchange; -use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\RequestFactoryInterface; +use h4kuna\Exchange\Download\SourceData; use Psr\Http\Message\ResponseInterface; use SimpleXMLElement; -/** - * @extends Exchange\Driver\Driver - */ -class Day extends Exchange\Driver\Driver +class Day implements Exchange\Driver\Source { public static string $url = 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml'; + private DateTimeZone $timeZone; + public function __construct( - ClientInterface $client, - RequestFactoryInterface $requestFactory, - string $timeZone = 'Europe/Berlin', - string $refresh = 'UTC', + string|DateTimeZone $timeZone = 'Europe/Berlin', + private string $refresh = 'midnight', ) { - parent::__construct($client, $requestFactory, $timeZone, $refresh); + $this->timeZone = Exchange\Utils::createTimeZone($timeZone); } - protected function createList(ResponseInterface $response): iterable + public function makeUrl(?DateTimeInterface $date): string { - $data = $response->getBody()->getContents(); + if ($date !== null) { + throw new Exchange\Exceptions\InvalidStateException('Ecb does not support history.'); + } + + return self::$url; + } + + + public function getTimeZone(): DateTimeZone + { + return $this->timeZone; + } - $xml = simplexml_load_string($data); + public function createSourceData(ResponseInterface $response): SourceData + { + $xml = simplexml_load_string($response->getBody()->getContents()); if ($xml === false) { throw new Exchange\Exceptions\InvalidStateException('Invalid source xml.'); } @@ -43,14 +53,16 @@ protected function createList(ResponseInterface $response): iterable $eur->addAttribute('currency', 'EUR'); $eur->addAttribute('rate', '1'); assert(isset($xml->Cube->Cube) && $xml->Cube->Cube->attributes() !== null); - $this->setDate('!Y-m-d', (string) $xml->Cube->Cube->attributes()['time']); + $date = Exchange\Utils::createFromFormat('!Y-m-d', (string) $xml->Cube->Cube->attributes()['time'], $this->timeZone); - return $xml->Cube->Cube->Cube; + return new SourceData($date, $this->refresh, $xml->Cube->Cube->Cube); } - protected function createProperty($row): Exchange\Currency\Property + public function createProperty(mixed $row): Exchange\Currency\Property { + assert($row instanceof SimpleXMLElement); + return new Exchange\Currency\Property( 1, floatval(strval($row->xpath('@rate')[0])), @@ -58,14 +70,4 @@ protected function createProperty($row): Exchange\Currency\Property ); } - - protected function prepareUrl(?DateTimeInterface $date): string - { - if ($date !== null) { - throw new Exchange\Exceptions\InvalidStateException('Ecb does not support history.'); - } - - return self::$url; - } - } diff --git a/src/Driver/RB/Day.php b/src/Driver/RB/Day.php index ef68a58..f054c5e 100644 --- a/src/Driver/RB/Day.php +++ b/src/Driver/RB/Day.php @@ -2,34 +2,55 @@ namespace h4kuna\Exchange\Driver\RB; +use DateTimeInterface; +use DateTimeZone; use h4kuna\Exchange\Currency\Property; -use h4kuna\Exchange\Driver\Driver; +use h4kuna\Exchange\Download\SourceData; +use h4kuna\Exchange\Driver\Source; use h4kuna\Exchange\Exceptions\InvalidStateException; -use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\RequestFactoryInterface; +use h4kuna\Exchange\Utils; use Psr\Http\Message\ResponseInterface; use SimpleXMLElement; -/** - * @extends Driver - */ -abstract class Day extends Driver +abstract class Day implements Source { + public static string $url = 'https://www.rb.cz/frontend-controller/backend-data/currency/listDataXml'; + private DateTimeZone $timeZone; + public function __construct( - ClientInterface $client, - RequestFactoryInterface $requestFactory, - string $timeZone = 'Europe/Prague', - string $refresh = 'midnight, +15 minute', + string|DateTimeZone $timeZone = 'Europe/Prague', + private string $refresh = 'midnight', ) { - parent::__construct($client, $requestFactory, $timeZone, $refresh); + $this->timeZone = Utils::createTimeZone($timeZone); + } + + + public function makeUrl(?DateTimeInterface $date): string + { + $url = self::$url; + + if ($date === null) { + return $url; + } + + return "$url?" . http_build_query([ + 'filtered' => 'true', + 'date' => $date->format('Y-m-d'), + ]); + } + + + public function getTimeZone(): DateTimeZone + { + return $this->timeZone; } - protected function createList(ResponseInterface $response): iterable + public function createSourceData(ResponseInterface $response): SourceData { $data = $response->getBody()->getContents(); $xml = simplexml_load_string($data); @@ -46,29 +67,24 @@ protected function createList(ResponseInterface $response): iterable $czk->addChild('exchangeRateSellCash', '1'); $czk->addChild('exchangeRateBuyCash', '1'); - $this->setDate(DATE_RFC3339_EXTENDED, (string) $xml->exchangeRateList->effectiveDateFrom); + $date = Utils::createFromFormat(DATE_RFC3339_EXTENDED, (string) $xml->exchangeRateList->effectiveDateFrom, $this->timeZone); - return $xml->exchangeRateList->exchangeRates->exchangeRate; + return new SourceData($date, $this->refresh, $xml->exchangeRateList->exchangeRates->exchangeRate); } - protected function createProperty($row) + public function createProperty(mixed $row): Property { + assert($row instanceof SimpleXMLElement); + return new Property( intval($row->unitsFrom), - $this->rate($row), + floatval((string) $this->rate($row)), strval($row->currencyFrom), ); } - abstract protected function rate(SimpleXMLElement $element): float; + abstract protected function rate(SimpleXMLElement $element): SimpleXMLElement; - - protected function prepareUrl(?\DateTimeInterface $date): string - { - $url = self::$url; - - return $date === null ? $url : "$url?filtered=true&date=" . urlencode($date->format('Y-m-d')); - } } diff --git a/src/Driver/RB/DayBuy.php b/src/Driver/RB/DayBuy.php index 63a2ec1..2151d6b 100644 --- a/src/Driver/RB/DayBuy.php +++ b/src/Driver/RB/DayBuy.php @@ -6,9 +6,10 @@ final class DayBuy extends Day { - protected function rate(SimpleXMLElement $element): float + + protected function rate(SimpleXMLElement $element): SimpleXMLElement { - return floatval($element->exchangeRateBuyCash); + return $element->exchangeRateBuyCash; } } diff --git a/src/Driver/RB/DayCenter.php b/src/Driver/RB/DayCenter.php index 596aaad..07a21e9 100644 --- a/src/Driver/RB/DayCenter.php +++ b/src/Driver/RB/DayCenter.php @@ -6,9 +6,10 @@ final class DayCenter extends Day { - protected function rate(SimpleXMLElement $element): float + + protected function rate(SimpleXMLElement $element): SimpleXMLElement { - return floatval($element->exchangeRateCenter); + return $element->exchangeRateCenter; } } diff --git a/src/Driver/RB/DaySell.php b/src/Driver/RB/DaySell.php index 11c8b05..f7ce0a1 100644 --- a/src/Driver/RB/DaySell.php +++ b/src/Driver/RB/DaySell.php @@ -6,9 +6,10 @@ final class DaySell extends Day { - protected function rate(SimpleXMLElement $element): float + + protected function rate(SimpleXMLElement $element): SimpleXMLElement { - return floatval($element->exchangeRateSellCash); + return $element->exchangeRateSellCash; } } diff --git a/src/Driver/Source.php b/src/Driver/Source.php new file mode 100644 index 0000000..cadaa72 --- /dev/null +++ b/src/Driver/Source.php @@ -0,0 +1,24 @@ + * @implements \ArrayAccess + * properties become readonly */ class Exchange implements \IteratorAggregate, \ArrayAccess { @@ -21,62 +20,44 @@ class Exchange implements \IteratorAggregate, \ArrayAccess public function __construct( - string|Property $from, - private RatingList\RatingListInterface $ratingList, - string|Property|null $to = null, + string $from, + public RatingListInterface $ratingList, + ?string $to = null, ) { - $this->setFrom($from); - if ($to === null) { - $this->to = $this->from; - } else { - $this->setTo($to); - } + $this->from = $this->get($from); + $this->to = $to === null ? $this->from : $this->get($to); } - public function getFrom(): Property + public function getFrom(?string $from = null): Property { - return $this->from; + return $from === null ? $this->from : $this->ratingList->get($from); } - public function modify(?string $to = null, ?string $from = null, ?CacheEntity $cacheEntity = null): static + public function getTo(?string $to = null): Property { - $exchange = clone $this; - if ($cacheEntity !== null) { - $exchange->ratingList = $this->ratingList->modify($cacheEntity); - } - - // add currency code instead of Property, because load new data from cache - $exchange->setFrom($from ?? $this->from->code); - $exchange->setTo($to ?? $this->to->code); - - return $exchange; - } - - - public function getTo(): Property - { - return $this->to; + return $to === null ? $this->to : $this->ratingList->get($to); } /** - * @deprecated use getFrom() + * @throws UnknownCurrencyException */ - public function getDefault(): Property + public function get(string $code): Property { - return $this->getFrom(); + return $this->ratingList->getSafe($code); } /** - * @deprecated use getTo() + * @return array{float, Property} + * @deprecated use change() and getTo() */ - public function getOutput(): Property + public function transfer(float $price, ?string $from = null, ?string $to = null): array { - return $this->getTo(); + return [$this->change($price, $from, $to), $this->getTo($to)]; } @@ -85,89 +66,47 @@ public function getOutput(): Property */ public function change(float $price, ?string $from = null, ?string $to = null): float { - return $this->transfer($price, $from, $to)[0]; - } - - - /** - * @return array{float, Property} - */ - public function transfer(float $price, ?string $from = null, ?string $to = null): array - { - $to = $to === null ? $this->to : $this->ratingList->get($to); if ($price === 0.0) { - return [0.0, $to]; + return 0.0; } - $from = $from === null ? $this->from : $this->ratingList->get($from); + $from = $this->getFrom($from); + $to = $this->getTo($to); if ($to !== $from) { $price *= $from->rate / $to->rate; } - return [$price, $to]; + return $price; } - /** - * @return Generator - */ - public function getIterator(): Generator + public function getIterator(): RatingListInterface { - foreach ($this->ratingList->all() as $code => $exists) { - yield $code => $this->ratingList->get($code); - } + return $this->ratingList; } public function offsetExists(mixed $offset): bool { - return isset($this->ratingList->all()[$offset]); + return $this->ratingList->offsetExists($offset); } public function offsetGet(mixed $offset): Property { - return $this->ratingList->get($offset); + return $this->get($offset); } public function offsetSet(mixed $offset, mixed $value): void { - throw new FrozenMethodException('not supported'); + $this->ratingList->offsetSet($offset, $value); } public function offsetUnset(mixed $offset): void { - throw new FrozenMethodException('not supported'); - } - - - /** - * @deprecated method will remove - */ - public function getRatingList(): RatingList\RatingList - { - assert($this->ratingList instanceof RatingList\RatingList); - return $this->ratingList; - } - - - public function getDate(): DateTimeImmutable - { - return $this->ratingList->getDate(); - } - - - protected function setFrom(string|Property $from): void - { - $this->from = $from instanceof Property ? $from : $this->ratingList->get(strtoupper($from)); - } - - - public function setTo(string|Property $to): void - { - $this->to = $to instanceof Property ? $to : $this->ratingList->get(strtoupper($to)); + $this->ratingList->offsetUnset($offset); } } diff --git a/src/ExchangeFactory.php b/src/ExchangeFactory.php index 0afd97c..04a1e56 100644 --- a/src/ExchangeFactory.php +++ b/src/ExchangeFactory.php @@ -2,69 +2,79 @@ namespace h4kuna\Exchange; -use DateTimeInterface; +use GuzzleHttp\Client; +use GuzzleHttp\Psr7\HttpFactory; use h4kuna\CriticalCache\CacheFactory; -use h4kuna\Exchange\Driver; +use h4kuna\Exchange\Currency\Property; +use h4kuna\Exchange\Download\SourceDownload; +use h4kuna\Exchange\Exceptions\MissingDependencyException; use h4kuna\Exchange\RatingList\CacheEntity; -use h4kuna\Exchange\RatingList\RatingList; use h4kuna\Exchange\RatingList\RatingListCache; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestFactoryInterface; -final class ExchangeFactory +final class ExchangeFactory implements ExchangeFactoryInterface { - /** - * @var array - */ - private array $allowedCurrencies; - - private Driver\DriverBuilderFactory $driverBuilderFactory; - - private CacheFactory $cacheFactory; + private RatingListCache $ratingListCache; /** * @param array $allowedCurrencies - * @param class-string $driver */ public function __construct( - private string $from, + private string $from = 'CZK', private ?string $to = null, array $allowedCurrencies = [], - ?Driver\DriverBuilderFactory $driverBuilderFactory = null, - ?CacheFactory $cacheFactory = null, - private string $driver = Driver\Cnb\Day::class, + ?RatingListCache $ratingListCache = null, ) { - $this->allowedCurrencies = Utils::transformCurrencies($allowedCurrencies); - $this->driverBuilderFactory = $driverBuilderFactory ?? new Driver\DriverBuilderFactory(); - $this->cacheFactory = $cacheFactory ?? $this->createCacheFactory(); + $this->ratingListCache = $ratingListCache ?? self::createRatingListCache(Utils::transformCurrencies($allowedCurrencies)); } - public function create(DateTimeInterface $date = null): Exchange + public function create( + ?string $from = null, + ?string $to = null, + ?CacheEntity $cacheEntity = null, + ): Exchange { - $cache = $this->createRatingListCache(); - return new Exchange( - $this->from, - new RatingList(new CacheEntity($date, $this->driver), $cache), - $this->to, + $from ?? $this->from, + $this->ratingListCache->build($cacheEntity ?? new CacheEntity()), + $to ?? $this->to, ); } - protected function createCacheFactory(): CacheFactory + /** + * @param array $allowedCurrencies + */ + private static function createRatingListCache(array $allowedCurrencies): RatingListCache + { + return new RatingListCache( + self::createCacheFactory()->create(), + new SourceDownload(self::createClient(), self::createRequestFactory(), $allowedCurrencies), + ); + } + + + private static function createCacheFactory(): CacheFactory { return new CacheFactory('exchange'); } - public function createRatingListCache(): RatingListCache + private static function createClient(): ClientInterface { - return new RatingListCache( - $this->allowedCurrencies, - $this->cacheFactory->create(), - $this->driverBuilderFactory->create() - ); + MissingDependencyException::guzzleClient(); + return new Client(); + } + + + private static function createRequestFactory(): RequestFactoryInterface + { + MissingDependencyException::guzzleFactory(); + return new HttpFactory(); } } diff --git a/src/ExchangeFactoryInterface.php b/src/ExchangeFactoryInterface.php new file mode 100644 index 0000000..5c37a67 --- /dev/null +++ b/src/ExchangeFactoryInterface.php @@ -0,0 +1,15 @@ +format('Y-m-d') >= date('Y-m-d')) { + $this->source = $source ?? new Day(); + + if ($date !== null && Utils::isTodayAndFuture($date, $this->source->getTimeZone())) { $date = null; } $this->date = $date; $this->cacheKey = $this->makeCacheKey(); $this->cacheKeyTtl = self::joinKey($this->cacheKey, 'ttl'); - $this->cacheKeyAll = self::joinKey($this->cacheKey, 'all'); - } - - - public function keyCode(string $code): string - { - return self::joinKey($this->cacheKey, $code); + $this->cacheKeyAll = self::joinKey($this->cacheKey, 'all.v6.1'); } private function makeCacheKey(): string { $key = $this->date === null ? '' : $this->date->format('.Y-m-d'); - return str_replace('\\', '.', $this->driver) . $key; + return str_replace('\\', '.', $this->source::class) . $key; } diff --git a/src/RatingList/RatingList.php b/src/RatingList/RatingList.php index d67bc4e..86649b6 100644 --- a/src/RatingList/RatingList.php +++ b/src/RatingList/RatingList.php @@ -2,123 +2,94 @@ namespace h4kuna\Exchange\RatingList; +use ArrayIterator; +use DateTime; use DateTimeImmutable; -use Generator; use h4kuna\Exchange\Currency\Property; use h4kuna\Exchange\Exceptions\FrozenMethodException; +use h4kuna\Exchange\Exceptions\UnknownCurrencyException; final class RatingList implements RatingListInterface { /** - * @var array|null + * @param array $properties */ - private ?array $all = null; - - private RatingListBuilder $ratingListBuilder; - - private ?DateTimeImmutable $date = null; - - private ?DateTimeImmutable $expire = null; - - - public function __construct(private CacheEntity $cacheEntity, private RatingListCache $ratingListCache) + public function __construct( + private DateTimeImmutable $date, + private ?DateTimeImmutable $request, // null is today + private ?DateTime $expire, // not null is for current + private array $properties, + ) { - $this->ratingListBuilder = new RatingListBuilder(); - $this->ratingListBuilder->setDefault(function (string|int $key): Property { - $this->getDate(); // init cache - assert(is_string($key)); - return $this->ratingListCache->currency($this->cacheEntity, $key); - }); } - public function modify(CacheEntity $cacheEntity): self + public function getRequest(): ?DateTimeImmutable { - return new self($cacheEntity, $this->ratingListCache); + return $this->request; } - public function get(string $code): Property + public function getIterator(): ArrayIterator { - return $this->ratingListBuilder->get($code); + return new ArrayIterator($this->properties); } - public function all(): array + public function offsetExists(mixed $offset): bool { - if ($this->all === null) { - $this->getDate(); // init cache - $this->all = $this->ratingListCache->all($this->cacheEntity); - } - - return $this->all; + return isset($this->properties[$offset]); } - public function getDate(): DateTimeImmutable + public function offsetGet(mixed $offset): Property { - if ($this->date === null) { - [ - 'date' => $this->date, - 'expire' => $this->expire, - ] = $this->ratingListCache->build($this->cacheEntity); - } - return $this->date; + return $this->get($offset); } - public function getExpire(): ?DateTimeImmutable + public function offsetSet(mixed $offset, mixed $value): void { - $this->getDate(); // init cache - return $this->expire; + throw new FrozenMethodException('deny, readonly'); } - /** - * @return Generator - * @deprecated moved to class Exchange - */ - public function getIterator(): Generator + public function offsetUnset(mixed $offset): void { - foreach ($this->all() as $code => $exists) { - yield $code => $this->get($code); - } + throw new FrozenMethodException('deny, readonly'); } - /** - * @deprecated moved to class Exchange - */ - public function offsetExists(mixed $offset): bool + public function get(string $code): Property { - return isset($this->all()[$offset]); + // no check if exist for fast + return $this->properties[$code]; } - /** - * @deprecated moved to class Exchange - */ - public function offsetGet(mixed $offset): Property + public function getSafe(string $code): Property { - return $this->get($offset); + if ($code === '') { + throw new UnknownCurrencyException('[empty string]'); + } + $code = strtoupper($code); + if ($this->offsetExists($code) === false) { + throw new UnknownCurrencyException($code); + } + + return $this->get($code); } - /** - * @deprecated moved to class Exchange - */ - public function offsetSet(mixed $offset, mixed $value): void + public function getDate(): DateTimeImmutable { - throw new FrozenMethodException('not supported'); + return $this->date; } - /** - * @deprecated moved to class Exchange - */ - public function offsetUnset(mixed $offset): void + public function getExpire(): ?DateTime { - throw new FrozenMethodException('not supported'); + return $this->expire; } } diff --git a/src/RatingList/RatingListBuilder.php b/src/RatingList/RatingListBuilder.php deleted file mode 100644 index c0828b4..0000000 --- a/src/RatingList/RatingListBuilder.php +++ /dev/null @@ -1,14 +0,0 @@ - - */ -final class RatingListBuilder extends LazyBuilder -{ - -} diff --git a/src/RatingList/RatingListCache.php b/src/RatingList/RatingListCache.php index 8ff634f..30fd3b5 100644 --- a/src/RatingList/RatingListCache.php +++ b/src/RatingList/RatingListCache.php @@ -2,159 +2,93 @@ namespace h4kuna\Exchange\RatingList; -use DateTimeImmutable; use h4kuna\CriticalCache\CacheLocking; use h4kuna\CriticalCache\Utils\Dependency; -use h4kuna\Exchange\Currency\Property; -use h4kuna\Exchange\Driver\Driver; -use h4kuna\Exchange\Driver\DriverAccessor; +use h4kuna\Exchange\Download\SourceDownloadInterface; use h4kuna\Exchange\Exceptions\InvalidStateException; -use h4kuna\Exchange\Exceptions\UnknownCurrencyException; use h4kuna\Exchange\Utils; -use h4kuna\Serialize\Serialize; +use Nette\Utils\DateTime; use Psr\Http\Client\ClientExceptionInterface; use Psr\SimpleCache\CacheInterface; -/** - * @phpstan-type cacheType array{date: DateTimeImmutable, expire: ?DateTimeImmutable, ttl: ?int} - */ final class RatingListCache { - public int $floatTtl = 900; // seconds -> 15 minutes + public int $floatTtl = Utils::CacheMinutes - DateTime::MINUTE; // 29 minutes - /** - * @param array $allowedCurrencies - */ public function __construct( - private array $allowedCurrencies, private CacheLocking $cache, - private DriverAccessor $driverAccessor, + private SourceDownloadInterface $sourceDownload, ) { } /** - * @return cacheType - * * @throws ClientExceptionInterface */ - public function build(CacheEntity $cacheEntity): array + public function build(CacheEntity $cacheEntity): RatingListInterface { - return $this->cache->load($cacheEntity->cacheKeyTtl, function ( + $ratingList = null; + + $this->cache->load($cacheEntity->cacheKeyTtl, function ( Dependency $dependency, CacheInterface $cache, string $prefix, - ) use ($cacheEntity): array { - $cacheType = $this->buildCache($cacheEntity, $cache, $prefix); - $dependency->ttl = $cacheType['ttl']; + ) use ($cacheEntity, &$ratingList): string { + [$ratingList, $ttl] = $this->buildCache($cacheEntity, $cache, $prefix); + $dependency->ttl = $ttl; - return $cacheType; + return $ratingList->getDate()->format(DATE_RFC3339); }); - } + if ($ratingList === null) { + $ratingList = $this->cache->get($cacheEntity->cacheKeyAll); - /** - * @throws ClientExceptionInterface - */ - public function rebuild(CacheEntity $cacheEntity): bool - { - /** - * @var ?cacheType $cacheTypeOld - */ - $cacheTypeOld = $this->cache->get($cacheEntity->cacheKeyTtl); - $cacheType = $this->buildCache($cacheEntity, $this->cache, ''); - $this->cache->set($cacheEntity->cacheKeyTtl, $cacheType, $cacheType['ttl']); - - return $cacheTypeOld === null || $cacheTypeOld['expire']?->format(DATE_ATOM) !== $cacheType['expire']?->format(DATE_ATOM); - } - - - public function currency(CacheEntity $cacheEntity, string $code): Property - { - $value = $this->cache->get($cacheEntity->keyCode($code)); - if ($value === null) { - throw new UnknownCurrencyException($code); + if (($ratingList instanceof RatingListInterface) === false) { + throw new InvalidStateException('Cache is broken.'); + } } - assert(is_string($value)); - $property = Serialize::decode($value); - assert($property instanceof Property); - return $property; + return $ratingList; } /** - * @return array + * @throws ClientExceptionInterface */ - public function all(CacheEntity $cacheEntity): array + public function rebuild(CacheEntity $cacheEntity): bool { - return $this->cache->load($cacheEntity->cacheKeyAll, static fn ( - ) => throw new InvalidStateException('Call build() first.')); + $oldValue = $this->cache->get($cacheEntity->cacheKeyTtl); + [$ratingList, $ttl] = $this->buildCache($cacheEntity, $this->cache, ''); + $value = $ratingList->getDate()->format(DATE_RFC3339); + $this->cache->set($cacheEntity->cacheKeyTtl, $value, $ttl); + + return $oldValue !== $value; } /** - * @return array{date: DateTimeImmutable, expire: ?DateTimeImmutable, ttl: ?int} - * * @throws ClientExceptionInterface + * @return array{RatingListInterface, ?int} */ private function buildCache(CacheEntity $cacheEntity, CacheInterface $cache, string $prefix): array { - $provider = $this->driverAccessor->get($cacheEntity->driver); - $all = []; try { - foreach ($provider->initRequest($cacheEntity->date, $this->allowedCurrencies) as $property) { - $cache->set($prefix . $cacheEntity->keyCode($property->code), Serialize::encode($property)); - $all[$property->code] = true; - } + $ratingList = $this->sourceDownload->execute($cacheEntity->source, $cacheEntity->date); } catch (ClientExceptionInterface $e) { - $data = $cache->get($prefix . $cacheEntity->cacheKeyAll) ?? []; - - if ($cacheEntity->date === null && $data !== []) { - return self::makeCacheData( - new DateTimeImmutable(), - new DateTimeImmutable(sprintf('+%s seconds', $this->floatTtl)), - $this->floatTtl, - ); + $ratingList = $cache->get($prefix . $cacheEntity->cacheKeyAll); + if ($ratingList === null) { + throw $e; } - throw $e; + assert($ratingList instanceof RatingListInterface); + $ratingList->getExpire()?->modify(sprintf('now, +%s seconds', Utils::CacheMinutes)); } - $cache->set($prefix . $cacheEntity->cacheKeyAll, $all); + $ttl = $ratingList->getExpire() === null ? null : Utils::countTTL($ratingList->getExpire(), $this->floatTtl); + $this->cache->set($prefix . $cacheEntity->cacheKeyAll, $ratingList); - return $cacheEntity->date === null - ? self::countCacheData($provider, $this->floatTtl) - : self::makeCacheData($provider->getDate()); + return [$ratingList, $ttl]; } - - /** - * @return cacheType - */ - private static function makeCacheData( - DateTimeImmutable $date, - ?DateTimeImmutable $expire = null, - ?int $ttl = null - ): array - { - return [ - 'date' => $date, - 'expire' => $expire, - 'ttl' => $ttl, - ]; - } - - - /** - * @return cacheType - */ - private static function countCacheData(Driver $provider, int $floatTtl): array - { - $expire = $provider->getRefresh(); - $ttl = Utils::countTTL($expire, $floatTtl); - - return self::makeCacheData($provider->getDate(), DateTimeImmutable::createFromMutable($expire), $ttl); - } } diff --git a/src/RatingList/RatingListInterface.php b/src/RatingList/RatingListInterface.php index 15e3c89..b599603 100644 --- a/src/RatingList/RatingListInterface.php +++ b/src/RatingList/RatingListInterface.php @@ -3,8 +3,10 @@ namespace h4kuna\Exchange\RatingList; use ArrayAccess; +use DateTime; use DateTimeImmutable; use h4kuna\Exchange\Currency\Property; +use h4kuna\Exchange\Exceptions\UnknownCurrencyException; use IteratorAggregate; /** @@ -13,24 +15,25 @@ */ interface RatingListInterface extends IteratorAggregate, ArrayAccess { + /** - * @return self - clone or new object + * check currency if exist before use */ - function modify(CacheEntity $cacheEntity): self; + function get(string $code): Property; - function get(string $code): Property; + function getRequest(): ?DateTimeImmutable; /** - * @return array + * @throws UnknownCurrencyException */ - function all(): array; + function getSafe(string $code): Property; function getDate(): DateTimeImmutable; - function getExpire(): ?DateTimeImmutable; + function getExpire(): ?DateTime; } diff --git a/src/Utils.php b/src/Utils.php index b575197..8b81a9b 100644 --- a/src/Utils.php +++ b/src/Utils.php @@ -3,14 +3,22 @@ namespace h4kuna\Exchange; use DateTime; +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; use h4kuna\DataType\Basic\Strings; +use h4kuna\Exchange\Exceptions\InvalidStateException; use Nette\StaticClass; +use Nette\Utils\DateTime as NetteDateTime; final class Utils { use StaticClass; + public const CacheMinutes = NetteDateTime::MINUTE * 30; + + /** * Stroke replace by point */ @@ -20,6 +28,61 @@ public static function stroke2point(string $str): string } + public static function createTimeZone(string|DateTimeZone $timeZone): DateTimeZone + { + return is_string($timeZone) ? new DateTimeZone($timeZone) : $timeZone; + } + + + public static function toImmutable(?DateTimeInterface $date, DateTimeZone $timeZone): ?DateTimeImmutable + { + if ($date === null) { + return null; + } + + if (self::isSameTimeOffsetTimeZone($date, $timeZone) === false || ($date instanceof DateTimeImmutable) === false) { + $date = (new DateTimeImmutable('now', $timeZone)) + ->setTimestamp($date->getTimestamp()); + } + + if (self::isTodayAndFuture($date, $timeZone)) { + return null; + } + + return $date; + } + + + private static function isSameTimeOffsetTimeZone(DateTimeInterface $date, DateTimeZone $timeZone): bool + { + $now = new DateTimeImmutable(); + return $date->getTimezone()->getOffset($now) === $timeZone->getOffset($now); + } + + + public static function isTodayAndFuture(DateTimeInterface $date, DateTimeZone $timeZone): bool + { + return $date->format('Y-m-d') >= (self::now($timeZone))->format('Y-m-d'); + } + + + public static function now(DateTimeZone $timeZone): DateTimeImmutable + { + return new DateTimeImmutable('now', $timeZone); + } + + + public static function createFromFormat(string $format, string $value, DateTimeZone $timezone): DateTimeImmutable + { + $date = DateTimeImmutable::createFromFormat($format, $value, $timezone); + if ($date === false) { + throw new InvalidStateException(sprintf('Can not create DateTime object from source "%s" with format "%s".', $value, $format)); + } + + return $date; + } + + /** * ['czk', 'eur'] => ['CZK' => 0, 'EUR' => 1] * diff --git a/tests/src/Caching/CacheTest.php b/tests/src/Caching/CacheTest.php deleted file mode 100644 index ff4c6e3..0000000 --- a/tests/src/Caching/CacheTest.php +++ /dev/null @@ -1,53 +0,0 @@ -createRatingListCache(); - - $cacheEntity = new Exchange\RatingList\CacheEntity(null, Exchange\Driver\Cnb\Day::class); - $date = $cache->build($cacheEntity); - Assert::same('2022-12-21', $date['date']->format('Y-m-d')); - $expected = new \DateTime('today 14:45:00'); - Exchange\Utils::countTTL($expected); - assert(isset($date['expire'])); - Assert::same($expected->format('Y-m-d H:i:s'), $date['expire']->format('Y-m-d H:i:s')); - - $cache->rebuild($cacheEntity); - - Assert::count(3, $cache->all($cacheEntity)); - } - - - public function testHistory(): void - { - $exchangeFactory = createExchangeFactory(); - $cache = $exchangeFactory->createRatingListCache(); - $cacheEntity = new Exchange\RatingList\CacheEntity(new \DateTime('2022-12-01'), Exchange\Driver\Cnb\Day::class); - - $date = $cache->build($cacheEntity); - Assert::same('2022-12-01', $date['date']->format('Y-m-d')); - Assert::null($date['expire']); - - $cache->rebuild($cacheEntity); - - Assert::count(3, $cache->all($cacheEntity)); - } - -} - -(new CacheTest())->run(); diff --git a/tests/src/Currency/PropertyTest.php b/tests/src/Currency/PropertyTest.php new file mode 100644 index 0000000..72277ae --- /dev/null +++ b/tests/src/Currency/PropertyTest.php @@ -0,0 +1,25 @@ +rate); + Assert::same('DOO', (string) $property); + } + +} + +(new PropertyTest())->run(); diff --git a/tests/src/Driver/Cnb/DayTest.php b/tests/src/Driver/Cnb/DayTest.php deleted file mode 100644 index 19f7a9f..0000000 --- a/tests/src/Driver/Cnb/DayTest.php +++ /dev/null @@ -1,33 +0,0 @@ - new Property(1, 1, 'CZK', 'Česká Republika', 'koruna'), - 'EUR' => new Property(1, 24.675, 'EUR', 'EMU', 'euro'), - 'JPY' => new Property(100, 15.809, 'JPY', 'Japonsko', 'jen'), - ]; - Assert::equal($expected, $list); - } - -} - -(new DayTest())->run(); diff --git a/tests/src/Driver/Ecb/DayTest.php b/tests/src/Driver/Ecb/DayTest.php deleted file mode 100644 index c6c956d..0000000 --- a/tests/src/Driver/Ecb/DayTest.php +++ /dev/null @@ -1,36 +0,0 @@ - Exchange\Fixtures\SourceListBuilder::make(Exchange\Driver\Ecb\Day::class, new DateTime('2022-12-15')), Exchange\Exceptions\InvalidStateException::class, 'Ecb does not support history.');; - } - -} - -(new DayTest())->run(); diff --git a/tests/src/Driver/RB/DayTest.php b/tests/src/Driver/RB/DayTest.php deleted file mode 100644 index 33d1096..0000000 --- a/tests/src/Driver/RB/DayTest.php +++ /dev/null @@ -1,68 +0,0 @@ - new Property(1, 24.67105, 'EUR'), - 'JPY' => new Property(100, 15.7468, 'JPY'), - 'CZK' => new Property(1, 1, 'CZK'), - ]; - Assert::equal($expected, $list); - } - - - public function testBuy(): void - { - $list = SourceListBuilder::make(DayBuy::class, new DateTime('2024-01-04')); - - $expected = [ - 'EUR' => new Property(1, 23.3659515, 'EUR'), - 'JPY' => new Property(100, 14.9137943, 'JPY'), - 'CZK' => new Property(1, 1, 'CZK'), - ]; - Assert::equal($expected, $list); - } - - - public function testSell(): void - { - $list = SourceListBuilder::make(DaySell::class, new DateTime('2024-01-04')); - - $expected = [ - 'EUR' => new Property(1, 25.9761485, 'EUR'), - 'JPY' => new Property(100, 16.5798057, 'JPY'), - 'CZK' => new Property(1, 1, 'CZK'), - ]; - Assert::equal($expected, $list); - } - -} - -(new DayTest())->run(); diff --git a/tests/src/E2E/ExchangeTest.php b/tests/src/E2E/ExchangeTest.php deleted file mode 100644 index 12ef48e..0000000 --- a/tests/src/E2E/ExchangeTest.php +++ /dev/null @@ -1,26 +0,0 @@ -create(new \DateTimeImmutable('2021-06-18')); - -Assert::same(3.918495297805643, $exchange->change(100.0)); -Assert::type('float', $exchange->change(100.0)); - -Assert::same('PHP', $exchange['PHP']->code); -Assert::same(0.44293, $exchange['PHP']->rate); - -$count = 0; -foreach ($exchange as $property) { - ++$count; -} - -Assert::same(34, $count); diff --git a/tests/src/E2E/SourceDownloadTest.php b/tests/src/E2E/SourceDownloadTest.php new file mode 100644 index 0000000..6144fe0 --- /dev/null +++ b/tests/src/E2E/SourceDownloadTest.php @@ -0,0 +1,255 @@ +execute(new Driver\RB\DayCenter(), null); + + $actual = new DateTimeImmutable('today 00:30', new DateTimeZone('Europe/Prague')); + Assert::same(self::format($actual), self::format($rateList->getExpire())); + Assert::null($rateList->getRequest()); + Assert::same(['EUR', 'USD', 'CZK'], array_keys((array) $rateList->getIterator())); + } + + + public function testRbPast(): void + { + $source = self::createSourceDownload(); + $date = self::pastDate(); + + // center + $rateList = $source->execute(new Driver\RB\DayCenter(), $date); + + $properties = [ + 'CZK' => new Property( + foreign: 1, + home: 1.0, + code: 'CZK', + ), + 'EUR' => new Property( + foreign: 1, + home: 24.8231, + code: 'EUR', + ), + 'USD' => new Property( + foreign: 1, + home: 22.93935, + code: 'USD', + ), + ]; + + Assert::null($rateList->getExpire()); + Assert::same(self::format($date), self::format($rateList->getRequest())); + Assert::same(self::format($date->modify('-1 day')), self::format($rateList->getDate())); + Assert::equal($properties, (array) $rateList->getIterator()); + + // sell + $rateList = $source->execute(new Driver\RB\DaySell(), $date); + $properties = [ + 'CZK' => new Property( + foreign: 1, + home: 1.0, + code: 'CZK', + ), + 'EUR' => new Property( + foreign: 1, + home: 26.136242, + code: 'EUR', + ), + 'USD' => new Property( + foreign: 1, + home: 24.1528416, + code: 'USD', + ), + ]; + + Assert::null($rateList->getExpire()); + Assert::same(self::format($date), self::format($rateList->getRequest())); + Assert::same(self::format($date->modify('-1 day')), self::format($rateList->getDate())); + Assert::equal($properties, (array) $rateList->getIterator()); + + // buy + $rateList = $source->execute(new Driver\RB\DayBuy(), $date); + $properties = [ + 'CZK' => new Property( + foreign: 1, + home: 1.0, + code: 'CZK', + ), + 'EUR' => new Property( + foreign: 1, + home: 23.509958, + code: 'EUR', + ), + 'USD' => new Property( + foreign: 1, + home: 21.7258584, + code: 'USD', + ), + ]; + + Assert::null($rateList->getExpire()); + Assert::same(self::format($date), self::format($rateList->getRequest())); + Assert::same(self::format($date->modify('-1 day')), self::format($rateList->getDate())); + Assert::equal($properties, (array) $rateList->getIterator()); + } + + + public function testCnbToday(): void + { + $source = self::createSourceDownload([]); + + $rateList = $source->execute(new Driver\Cnb\Day(), null); + + $actual = new DateTimeImmutable('today 15:00', new DateTimeZone('Europe/Prague')); + Assert::same(self::format($actual), self::format($rateList->getExpire())); + Assert::null($rateList->getRequest()); + Assert::same([ + 'CZK', + 'AUD', + 'BRL', + 'BGN', + 'CNY', + 'DKK', + 'EUR', + 'PHP', + 'HKD', + 'INR', + 'IDR', + 'ISK', + 'ILS', + 'JPY', + 'ZAR', + 'CAD', + 'KRW', + 'HUF', + 'MYR', + 'MXN', + 'XDR', + 'NOK', + 'NZD', + 'PLN', + 'RON', + 'SGD', + 'SEK', + 'CHF', + 'THB', + 'TRY', + 'USD', + 'GBP', + ], array_keys((array) $rateList->getIterator())); + } + + + public function testCnbPast(): void + { + $source = self::createSourceDownload(); + $request = self::pastDate(); + + $rateList = $source->execute(new Driver\Cnb\Day(), $request); + + $properties = [ + 'CZK' => new Driver\Cnb\Property( + foreign: 1, + home: 1.0, + code: 'CZK', + country: 'Česká Republika', + name: 'koruna', + ), + 'EUR' => new Driver\Cnb\Property( + foreign: 1, + home: 24.875, + code: 'EUR', + country: 'EMU', + name: 'euro', + ), + 'USD' => new Driver\Cnb\Property( + foreign: 1, + home: 22.853, + code: 'USD', + country: 'USA', + name: 'dolar', + ), + ]; + + Assert::null($rateList->getExpire()); + Assert::same(self::format($request), self::format($rateList->getRequest())); + Assert::same(self::format($request->modify('-1 day')), self::format($rateList->getDate())); + Assert::equal($properties, (array) $rateList->getIterator()); + } + + + public function testEcbToday(): void + { + $source = self::createSourceDownload(); + + $rateList = $source->execute(new Driver\Ecb\Day(), null); + + $actual = new DateTimeImmutable('today 00:30', new DateTimeZone('Europe/Berlin')); + Assert::same(self::format($actual), self::format($rateList->getExpire())); + Assert::null($rateList->getRequest()); + Assert::same(['USD', 'CZK', 'EUR'], array_keys((array) $rateList->getIterator())); + } + + + public function testEcbPast(): void + { + Assert::exception(function () { + $source = self::createSourceDownload(); + + $source->execute(new Driver\Ecb\Day(), self::pastDate()); + }, InvalidStateException::class, 'Ecb does not support history.'); + } + + + private static function format(?DateTimeInterface $dateTime): string + { + return $dateTime === null ? '' : $dateTime->format(DateTimeInterface::RFC3339); + } + + + private static function pastDate(): DateTimeImmutable + { + return new DateTimeImmutable('2024-02-03', new DateTimeZone('Europe/Prague')); + } + + + /** + * @param array|null $allowedCurrencies + */ + private static function createSourceDownload(?array $allowedCurrencies = null): SourceDownload + { + return new SourceDownload(new Client(), new HttpFactory(), Utils::transformCurrencies($allowedCurrencies ?? [ + 'CZK', + 'EUR', + 'USD', + ])); + } +} + +(new SourceDownloadTest())->run(); diff --git a/tests/src/ExchangeFactoryTest.php b/tests/src/ExchangeFactoryTest.php new file mode 100644 index 0000000..6c461bf --- /dev/null +++ b/tests/src/ExchangeFactoryTest.php @@ -0,0 +1,27 @@ +create(cacheEntity: new CacheEntity(new \DateTime('2000-12-18'))); + + $v = $exchange->change(100, 'EUR', 'USD'); + + Assert::true($v > 0); + } +} + +(new ExchangeFactoryTest())->run(); diff --git a/tests/src/ExchangeTest.php b/tests/src/ExchangeTest.php index 96b4b8e..cd02656 100644 --- a/tests/src/ExchangeTest.php +++ b/tests/src/ExchangeTest.php @@ -1,61 +1,114 @@ -create(new \DateTime('2022-12-20')); +use h4kuna\CriticalCache\CacheLocking; +use h4kuna\Exchange\Currency\Property; +use h4kuna\Exchange\Download\SourceDownloadInterface; +use h4kuna\Exchange\Exceptions\FrozenMethodException; +use h4kuna\Exchange\Exceptions\UnknownCurrencyException; +use h4kuna\Exchange\Exchange; +use h4kuna\Exchange\RatingList\CacheEntity; +use h4kuna\Exchange\RatingList\RatingList; +use h4kuna\Exchange\RatingList\RatingListCache; +use Tester\Assert; +use Tester\TestCase; -// change driver -Assert::same('EUR', $exchange->getFrom()->code); -Assert::same($exchange->getTo(), $exchange->getFrom()); -Assert::same($exchange->getOutput(), $exchange->getDefault()); -Assert::true(isset($exchange['EUR'])); +final class ExchangeTest extends TestCase +{ + public function testGetRatingList(): void + { + $exchange = self::createExchange(); + Assert::same($exchange->ratingList, $exchange->getIterator()); + } -Assert::same(100.0, $exchange->change(100)); -Assert::same(26.0, $exchange->change(1, 'EUR', 'CZK')); -Assert::same(50.0, $exchange->change(100, 'USD', 'EUR')); -Assert::same(200.0, $exchange->change(100, null, 'USD')); -Assert::same(50.0, $exchange->change(100, 'USD')); + public function testChange(): void + { + $exchange = self::createExchange(); -$result = $exchange->transfer(100, 'USD'); -Assert::same(50.0, $result[0]); -Assert::type(Exchange\Driver\Cnb\Property::class, $result[1]); -Assert::same('EUR', $result[1]->code); -Assert::same('EUR', (string) $result[1]); + Assert::same(0.0, $exchange->change(0)); + Assert::same(100.0, $exchange->change(100)); + Assert::same(50.0, $exchange->change(100, 'USD')); + Assert::same(200.0, $exchange->change(100, null, 'USD')); + Assert::same(26.0, $exchange->change(1, 'EUR', 'CZK')); + Assert::same(50.0, $exchange->change(100, 'USD', 'EUR')); + + Assert::exception(function () use ($exchange) { + Assert::error(fn () => $exchange->change(100, 'BBB', 'EUR'), E_WARNING); + }, \TypeError::class); + + Assert::exception(function () use ($exchange) { + Assert::error(fn () => $exchange->change(100, 'USD', ''), E_WARNING); + }, \TypeError::class); + } + + + public function testIteratorAggregate(): void + { + $exchange = self::createExchange(); + + $codes = []; + foreach ($exchange as $k => $v) { + $codes[] = $k; + } + + Assert::same(['CZK', 'EUR', 'USD'], $codes); + } -foreach ($exchange as $code => $property) { - Assert::type(Exchange\Currency\Property::class, $property); -} -$exchange2 = $exchange->modify('CZK', 'EUR'); -Assert::same(2600.0, $exchange2->change(100)); -Assert::same(0.0, $exchange2->change(0)); + public function testArrayAccess(): void + { + $exchange = self::createExchange(); + Assert::same('EUR', $exchange['EUR']->code); + Assert::true(isset($exchange['EUR'])); + Assert::false(isset($exchange['CCC'])); -Assert::exception(function () use ($exchange) { - unset($exchange['EUR']); -}, Exchange\Exceptions\FrozenMethodException::class); + Assert::exception(function () use ($exchange) { + unset($exchange['EUR']); + }, FrozenMethodException::class); -Assert::exception(function () use ($exchange) { - $exchange['EUR'] = 1; // @phpstan-ignore-line -}, Exchange\Exceptions\FrozenMethodException::class); + Assert::exception(function () use ($exchange) { + $exchange['EUR'] = 'foo'; // @phpstan-ignore-line + }, FrozenMethodException::class); -$exchange2 = $exchange->modify(null, null, new Exchange\RatingList\CacheEntity(new \DateTime('2022-12-01'), Driver\Cnb\Day::class)); -Assert::same(24.0, $exchange2['EUR']->rate); + Assert::exception(fn () => $exchange['AAA'], UnknownCurrencyException::class); -Assert::exception(static function() use ($exchange) { - $exchange->modify(cacheEntity: new Exchange\RatingList\CacheEntity(new \DateTime('2022-12-02'), Driver\Cnb\Day::class)); -}, ClientExceptionInterface::class); + Assert::exception(fn () => $exchange->get('AAA'), UnknownCurrencyException::class); + } + + + private static function createExchange(): Exchange + { + $ratingList = new RatingList(new \DateTimeImmutable(), null, null, [ + 'CZK' => new Property(1, 1, 'CZK'), + 'EUR' => new Property(1, 26, 'EUR'), + 'USD' => new Property(10, 130, 'USD'), + ]); + + $ratingList2 = new RatingList(new \DateTimeImmutable(), null, null, [ + 'CZK' => new Property(1, 1, 'CZK'), + 'EUR' => new Property(1, 28, 'EUR'), + 'USD' => new Property(10, 135, 'USD'), + ]); + + $ratingListCache = mock(CacheLocking::class) + ->makePartial(); + $ratingListCache->shouldReceive('load') + ->andReturn(null); + $ratingListCache->shouldReceive('get') + ->andReturn($ratingList, $ratingList2); + + $sourceDownload = mock(SourceDownloadInterface::class); + + $ratingListCache = new RatingListCache($ratingListCache, $sourceDownload); + + return new Exchange('EUR', $ratingListCache->build(new CacheEntity())); + } +} -$exchange3 = $exchange->modify(cacheEntity: new Exchange\RatingList\CacheEntity(new \DateTime(), Driver\Cnb\Day::class)); -Assert::same(25.0, $exchange3['EUR']->rate); -unlink(TEMP_DIR . '/exchange/h4kuna/cache/_.h4kuna.Exchange.Driver.Cnb.Day.ttl'); -Exchange\Fixtures\HttpFactory::$exception = true; -$exchange4 = $exchange->modify(cacheEntity: new Exchange\RatingList\CacheEntity(null, Driver\Cnb\Day::class)); -Assert::same(25.0, $exchange4['EUR']->rate); +(new ExchangeTest())->run(); diff --git a/tests/src/RatingList/RatingListCacheTest.php b/tests/src/RatingList/RatingListCacheTest.php new file mode 100644 index 0000000..874050a --- /dev/null +++ b/tests/src/RatingList/RatingListCacheTest.php @@ -0,0 +1,215 @@ +shouldReceive('execute') + ->andReturn($ratingList); + + $ratingListCache = new RatingListCache($cacheLocking, $source); + + $list = $ratingListCache->build(new CacheEntity()); + + Assert::same($ratingList, $list); + } + + + public function testBackupBuild(): void + { + $ratingList = self::createRatingList(); + $ratingList2 = self::createRatingList(); + $cache = self::createCache(); + $cache->shouldReceive('get') + ->andReturn($ratingList2); + + $cacheLocking = self::createCacheLocking($ratingList, $cache, 6800); + $cacheLocking->shouldReceive('set') + ->with('a.h4kuna.Exchange.Driver.Cnb.Day.all.v6.1', $ratingList2); + $source = self::createSourceDownload(); + $source->shouldReceive('execute') + ->withArgs(function () { + throw new class extends \Exception implements ClientExceptionInterface { + + }; + }); + + $ratingListCache = new RatingListCache($cacheLocking, $source); + + $ratingListActual = $ratingListCache->build(new CacheEntity()); + Assert::same($ratingList2, $ratingListActual); + } + + + public function testRebuild(): void + { + $ratingList = self::createRatingList(); + $ratingList2 = self::createRatingList(); + + $cacheLocking = mock(CacheLocking::class) + ->makePartial(); + $cacheLocking->shouldReceive('get') + ->with('h4kuna.Exchange.Driver.Cnb.Day.ttl') + ->andReturn($ratingList, $ratingList2); + + $cacheLocking->shouldReceive('set') + ->with('h4kuna.Exchange.Driver.Cnb.Day.all.v6.1', $ratingList2) + ->andReturn(true); + $cacheLocking->shouldReceive('set') + ->with('h4kuna.Exchange.Driver.Cnb.Day.ttl', (new \DateTime('now'))->format(\DateTime::RFC3339), 5000) + ->andReturn(true); + + $source = self::createSourceDownload(); + $source->shouldReceive('execute') + ->andReturn($ratingList2); + + $ratingListCache = new RatingListCache($cacheLocking, $source); + + $result = $ratingListCache->rebuild(new CacheEntity()); + Assert::true($result); + } + + + public function testNotingByLoadBuild(): void + { + $ratingList = self::createRatingList(); + $ratingList2 = self::createRatingList(); + $cache = self::createCache(); + + $cacheLocking = self::createCacheLocking($ratingList, $cache, load: fn () => true); + $cacheLocking->shouldReceive('get') + ->andReturn($ratingList2); + $cacheLocking->shouldReceive('set') + ->andReturn(true); + + $cacheLocking->shouldReceive('load') + ->withArgs(function () { + return true; + }); + $source = self::createSourceDownload(); + $source->shouldReceive('execute') + ->withArgs(function () { + throw new class extends \Exception implements ClientExceptionInterface { + + }; + }); + + $ratingListCache = new RatingListCache($cacheLocking, $source); + + $ratingListActual = $ratingListCache->build(new CacheEntity()); + Assert::same($ratingList2, $ratingListActual); + } + + + public function testFatalFailedBuild(): void + { + $ratingList = self::createRatingList(); + $cache = self::createCache(); + $cache->shouldReceive('get') + ->andReturn(null); + + $cacheLocking = self::createCacheLocking($ratingList, $cache); + $source = self::createSourceDownload(); + $source->shouldReceive('execute') + ->withArgs(function () { + throw new class extends \Exception implements ClientExceptionInterface { + + }; + }); + + $ratingListCache = new RatingListCache($cacheLocking, $source); + + Assert::exception(fn () => $ratingListCache->build(new CacheEntity()), ClientExceptionInterface::class); + } + + + /** + * @return Mock&SourceDownloadInterface + */ + private static function createSourceDownload() + { + $source = mock(SourceDownloadInterface::class) + ->makePartial(); + + return $source; + } + + + /** + * @return Mock&CacheInterface + */ + private static function createCache() + { + return mock(CacheInterface::class) + ->makePartial(); + } + + + /** + * @return Mock&CacheLocking + */ + private static function createCacheLocking( + RatingList $ratingList, + ?CacheInterface $cache = null, + int $ttl = 5000, + ?Closure $load = null + ) + { + $cache = $cache ?? self::createCache(); + + $load ??= function (string $key, Closure $callback) use ($cache, $ttl) { + $dependency = new Dependency(); + $callback($dependency, $cache, 'a.'); + Assert::same($ttl, $dependency->ttl); + + return true; + }; + + $cacheLocking = mock(CacheLocking::class) + ->makePartial(); + $cacheLocking->shouldReceive('set') + ->with('a.h4kuna.Exchange.Driver.Cnb.Day.all.v6.1', $ratingList); + + $cacheLocking->shouldReceive('load') + ->withArgs($load); + + return $cacheLocking; + } + + + private static function createRatingList(): RatingList + { + return new RatingList(new \DateTimeImmutable(), null, new \DateTime('+5000 seconds'), [ + 'CZK' => new Property(1, 1, 'CZK'), + 'EUR' => new Property(1, 26, 'EUR'), + 'USD' => new Property(10, 130, 'USD'), + ]); + } + +} + +(new RatingListCacheTest())->run(); diff --git a/tests/src/RatingList/RatingListTest.php b/tests/src/RatingList/RatingListTest.php index 91d6932..ce82d4b 100644 --- a/tests/src/RatingList/RatingListTest.php +++ b/tests/src/RatingList/RatingListTest.php @@ -1,57 +1,32 @@ -create(); -$ratingList = $exchange->getRatingList(); - -Assert::same(20.0, $ratingList['USD']->rate); -Assert::same(25.0, $ratingList['EUR']->rate); -Assert::true(isset($ratingList['EUR'])); - -Assert::exception(function () use ($ratingList) { - $ratingList->offsetSet('XXX', new Exchange\Currency\Property( - 1, - 1, - 'XXX', - )); -}, Exceptions\FrozenMethodException::class); - -Assert::exception(function () use ($ratingList) { - $ratingList->offsetUnset('XXX'); -}, Exceptions\FrozenMethodException::class); - -Assert::exception(function () use ($ratingList) { - $ratingList['QWE']; -}, Exchange\Exceptions\UnknownCurrencyException::class); - -Assert::type(Exchange\Currency\Property::class, $ratingList->offsetGet('CZK')); - -Assert::exception(function () use ($ratingList) { - $ratingList['CZK'] = new Exchange\Currency\Property( - 1, - 1, - 'XXX', - ); -}, Exceptions\FrozenMethodException::class); - -Assert::exception(function () use ($ratingList) { - unset($ratingList['CZK']); -}, Exceptions\FrozenMethodException::class); - -$list = []; -foreach ($ratingList as $item) { - $list[] = $item; +use h4kuna\Exchange\Currency\Property; +use h4kuna\Exchange\Exceptions\UnknownCurrencyException; +use h4kuna\Exchange\RatingList\RatingList; +use Tester\Assert; +use Tester\TestCase; + +final class RatingListTest extends TestCase +{ + public function testBasic(): void + { + $ratingList = new RatingList(new \DateTimeImmutable(), null, null, [ + 'CZK' => new Property(1, 1, 'CZK'), + 'EUR' => new Property(1, 26, 'EUR'), + 'USD' => new Property(10, 130, 'USD'), + ]); + + Assert::same(26.0, $ratingList['EUR']->rate); + + Assert::exception(fn () => $ratingList->getSafe(''), UnknownCurrencyException::class, '[empty string]'); + Assert::exception(fn () => $ratingList->getSafe('AAA'), UnknownCurrencyException::class, 'AAA'); + } } -Assert::count(3, $list); +(new RatingListTest())->run(); diff --git a/tests/src/TimestampTimeZoneTest.php b/tests/src/TimestampTimeZoneTest.php index 9aa9589..11d71f7 100644 --- a/tests/src/TimestampTimeZoneTest.php +++ b/tests/src/TimestampTimeZoneTest.php @@ -16,13 +16,17 @@ final class TimestampTimeZoneTest extends TestCase { public function testDefault(): void { - $date = new DateTime('1986-12-30 5:30:57', new DateTimeZone('America/Adak')); + $adak = new DateTimeZone('America/Adak'); + $prague = new DateTimeZone('Europe/Prague'); + $date = new DateTime('1986-12-30 5:30:57', $adak); - $newDate = new DateTime('now', new DateTimeZone('Europe/Prague')); + $newDate = new DateTime('now', $prague); $newDate->setTimestamp($date->getTimestamp()); Assert::same('1986-12-30 05:30:57', $date->format('Y-m-d H:i:s')); Assert::same('1986-12-30 16:30:57', $newDate->format('Y-m-d H:i:s')); + + Assert::notSame((new DateTime('midnight', $prague))->getTimestamp(), (new DateTime('midnight', $adak))->getTimestamp()); } } diff --git a/tests/src/UtilsTest.php b/tests/src/UtilsTest.php index 1304915..57c4ea5 100644 --- a/tests/src/UtilsTest.php +++ b/tests/src/UtilsTest.php @@ -6,6 +6,9 @@ use Closure; use DateTime; +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; use h4kuna\Exchange\Utils; use Tester\Assert; use Tester\TestCase; @@ -17,12 +20,12 @@ final class UtilsTest extends TestCase /** * @return array */ - public function data(): array + public function dataCountTTL(): array { return [ [ function (self $self) { - $self->assert( + $self->assertCountTTL( 901, new DateTime('+901 seconds'), ); @@ -30,7 +33,7 @@ function (self $self) { ], [ function (self $self) { - $self->assert( + $self->assertCountTTL( 87300, new DateTime('+900 seconds'), ); @@ -38,7 +41,7 @@ function (self $self) { ], [ function (self $self) { - $self->assert( + $self->assertCountTTL( 87300, new DateTime('2023-01-01 14:45:00'), (new DateTime('2023-01-01 14:45:00, -900 seconds'))->getTimestamp(), @@ -47,7 +50,7 @@ function (self $self) { ], [ function (self $self) { - $self->assert( + $self->assertCountTTL( 901, new DateTime('2023-01-01 14:45:00'), (new DateTime('2023-01-01 14:45:00, -901 seconds'))->getTimestamp(), @@ -60,7 +63,7 @@ function (self $self) { /** * @param Closure(static):void $assert - * @dataProvider data + * @dataProvider dataCountTTL */ public function testCountTTL(Closure $assert): void { @@ -68,7 +71,7 @@ public function testCountTTL(Closure $assert): void } - public function assert( + public function assertCountTTL( int $expectedTime, DateTime $from, int $time = 0 @@ -80,6 +83,100 @@ public function assert( Assert::same($expectedTime, Utils::countTTL($from, 900, $time)); } } + + + /** + * @return array + */ + public function dataToImmutable(): array + { + return [ + [ + function (self $self) { + $self->assertToImmutable( + null, + null, + ); + }, + ], + [ + function (self $self) { + $self->assertToImmutable( + null, + new DateTime(), + ); + }, + ], + [ + function (self $self) { + $self->assertToImmutable( + null, + new DateTimeImmutable(), + ); + }, + ], + [ + function (self $self) { + $self->assertToImmutable( + null, + new DateTimeImmutable('now', new DateTimeZone('America/Adak')), + ); + }, + ], + [ + function (self $self) { + $self->assertToImmutable( + '1986-12-30T15:16:17+01:00', + new DateTimeImmutable('1986-12-30 15:16:17'), + ); + }, + ], + [ + function (self $self) { + $self->assertToImmutable( + '1986-12-30T15:16:17+01:00', + new DateTime('1986-12-30 15:16:17', new DateTimeZone('Europe/Berlin')), + ); + }, + ], + [ + function (self $self) { + $self->assertToImmutable( + '1986-12-30T15:16:17+01:00', + new DateTimeImmutable('1986-12-30 15:16:17', new DateTimeZone('Europe/Berlin')), + ); + }, + ], + [ + function (self $self) { + $self->assertToImmutable( + '1986-12-31T02:16:17+01:00', + new DateTime('1986-12-30 15:16:17', new DateTimeZone('America/Adak')), + ); + }, + ], + ]; + } + + + /** + * @param Closure(static):void $assert + * @dataProvider dataToImmutable + */ + public function testToImmutable(Closure $assert): void + { + $assert($this); + } + + + public function assertToImmutable( + ?string $expected, + ?DateTimeInterface $date + ): void + { + Assert::same($expected, Utils::toImmutable($date, new DateTimeZone('Europe/Prague'))?->format(DateTimeInterface::RFC3339)); + } + } (new UtilsTest())->run();