From 9e003ded0ae1e7ecbb98e88f15de28e1bb4fd7ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milan=20Mat=C4=9Bj=C4=8Dek?= Date: Fri, 16 Feb 2024 16:10:12 +0100 Subject: [PATCH] feat(Exchange)!: improve store and invalid cache --- README.md | 83 +++--- changelog.md | 6 + composer.json | 8 +- examples/index.php | 49 ++-- 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 | 70 +++-- src/Driver/RB/DayBuy.php | 5 +- src/Driver/RB/DayCenter.php | 5 +- src/Driver/RB/DaySell.php | 5 +- src/Driver/Source.php | 24 ++ src/Exceptions/FrozenMethodException.php | 4 +- src/Exceptions/InvalidStateException.php | 4 +- src/Exceptions/MissingDependencyException.php | 15 +- src/Exceptions/UnknownCurrencyException.php | 4 +- src/Exchange.php | 141 +++------- src/ExchangeFactory.php | 73 ++--- src/ExchangeFactoryInterface.php | 15 ++ src/RatingList/CacheEntity.php | 33 +-- src/RatingList/RatingList.php | 107 +++----- src/RatingList/RatingListBuilder.php | 14 - src/RatingList/RatingListCache.php | 136 +++------- src/RatingList/RatingListInterface.php | 17 +- src/Utils.php | 68 ++++- 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 +++++++- 45 files changed, 1389 insertions(+), 1037 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..b8986b1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Exchange [![Total Downloads](https://poser.pugx.org/h4kuna/exchange/downloads?format=flat)](https://packagist.org/packages/h4kuna/exchange) [![License](https://poser.pugx.org/h4kuna/exchange/license?format=flat)](https://packagist.org/packages/h4kuna/exchange) -Exchange is PHP script works with currencies. You can convert price, format add VAT or only render exchange rates. +Exchange is PHP script works with currencies. You can convert price. Here is [changelog](changelog.md). @@ -14,12 +14,17 @@ Here is [changelog](changelog.md). - [Nette extension](//github.com/h4kuna/exchange-nette) -Installation via composer ------------------------ +## Installation via composer ```sh $ composer require h4kuna/exchange ``` +Optional packages +```sh +$ composer require guzzlehttp/guzzle guzzlehttp/psr7 h4kuna/dir nette/caching +``` + +Support PSR-6 for cache. ## How to use @@ -31,29 +36,42 @@ For example define own exchange rates: - 20 CZK = 1 USD ```php -use h4kuna\CriticalCache;use h4kuna\Exchange; - -$cacheFactory = new CriticalCache\CacheFactory('exchange'); - -$exchangeFactory = new Exchange\ExchangeFactory( - from: 'eur', - to: 'usd', - allowedCurrencies: [ - 'CZK', - 'USD', - 'eur', // lower case will be changed to upper case - ], - cacheFactory: $cacheFactory -); +use h4kuna\Exchange\Currency\Property; +use h4kuna\Exchange\Driver\Cnb\Day; +use h4kuna\Exchange\Exchange; +use h4kuna\Exchange\ExchangeFactory; +use h4kuna\Exchange\RatingList\CacheEntity; +use h4kuna\Exchange\RatingList\RatingList; + +{ # by factory + $exchangeFactory = new ExchangeFactory( + from: 'eur', + to: 'usd', + allowedCurrencies: [ + 'CZK', + 'USD', + 'eur', // lower case will be changed to upper case + ], + ); + + $exchange = $exchangeFactory->create(); +} -$exchange = $exchangeFactory->create(); +{ # custom RatingList + $ratingList = new RatingList(new DateTimeImmutable(), new DateTimeImmutable(), null, [ + 'EUR' => new Property(1, 25.0, 'EUR'), + 'USD' => new Property(1, 20.0, 'USD'), + 'CZK' => new Property(1, 1.0, 'CZK'), + ]); + $exchange = new Exchange('EUR', $ratingList, 'USD'); +} -echo $exchange->change(100); // EUR -> USD = 125.0 +echo $exchange->change(100) . PHP_EOL; // EUR -> USD = 125.0 // use only upper case -echo $exchange->change(100, 'CZK'); // CZK -> USD = 5.0 -echo $exchange->change(100, NULL, 'CZK'); // EUR -> CZK = 2500.0 -echo $exchange->change(100, 'USD', 'CZK'); // USD -> CZK = 2000.0 +echo $exchange->change(100, 'CZK') . PHP_EOL; // CZK -> USD = 5.0 +echo $exchange->change(100, null, 'CZK') . PHP_EOL; // EUR -> CZK = 2500.0 +echo $exchange->change(100, 'USD', 'CZK') . PHP_EOL; // USD -> CZK = 2000.0 ``` ### Change driver and date @@ -64,36 +82,35 @@ Download history exchange rates. Make new instance of Exchange with history rate use h4kuna\Exchange\RatingList; use h4kuna\Exchange; -$exchangePast = $exchange->modify(cacheEntity: new RatingList\CacheEntity(new \Datetime('2000-12-30'), Exchange\Driver\Cnb\Day::class)); -$exchangePast->change(100); +$exchangePast = $exchangeFactory->create(cacheEntity: new CacheEntity(new Datetime('2000-12-30'), new Day)); +echo $exchangePast->change(100) . PHP_EOL; ``` ### Access and iterator ```php -/* @var $property Exchange\Currenry\Property */ +use h4kuna\Exchange\Currency\Property; +/* @var $property Property */ $property = $exchange['EUR']; var_dump($property); - +echo PHP_EOL; foreach ($exchange as $code => $property) { - /* @var $property Exchange\Currenry\Property */ - var_dump($property); + /* @var $property Property */ + var_dump($code, $property); } ``` ## Caching -The cache invalid automatic at some time, defined by property `Driver::$refresh`. From this property is counted time to live. Little better is invalid cache by cron. Because one request on server does not lock other requests. Let's run cron max. 15 minutes before invalidate cache. +The cache invalid automatic at some time, defined by property `SourceData::$refresh`. From this property is counted time to live. Little better is invalid cache by cron. Because one request on server does not lock other requests. Let's run cron max. 29 minutes before invalidate cache. ```php use h4kuna\Exchange\RatingList\RatingListCache; use h4kuna\Exchange\RatingList\CacheEntity; use h4kuna\Exchange\Driver\Cnb\Day; /** @var RatingListCache $ratingListCache */ -$ratingListCache->rebuild(new CacheEntity(null, Day::class)); +$ratingListCache->rebuild(new CacheEntity(null, new Day)); ``` -In example, is used `h4kuna\Exchange\Driver\Cnb\Day::$refresh` is defined at 15:00. Run cron 14:55 every day. - - +In example, is used `h4kuna\Exchange\Driver\Cnb\Day::$refresh` is defined at 14:30 + 30 minute the cache is valid. Run cron 14:32 every day. diff --git a/changelog.md b/changelog.md index af098af..74b4068 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,11 @@ # Changelog +## v7.1 +- `Exchange::modify()` was removed, replace by ExchangeFactory +- `Exchange::transfer()` was removed, use change($amount, $from, $to) and getTo($to) of getFrom($from) +- improve [RatingListCache.php](./src/RatingList/RatingListCache.php) +- remove dependency `h4kuna/serialize-polyfill` + ## v7.0 - for your temporary rate implement by own RatingList diff --git a/composer.json b/composer.json index 85f20fb..72817a3 100644 --- a/composer.json +++ b/composer.json @@ -13,21 +13,21 @@ ], "require": { "php": ">=8.0", - "h4kuna/critical-cache": "^v0.1.3", - "h4kuna/data-type": "^v3.0.7", - "h4kuna/serialize-polyfill": "^0.2.2", + "h4kuna/critical-cache": "^v0.1.5", "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", + "phpstan/phpstan-deprecation-rules": "^1.1.3", "phpstan/phpstan-strict-rules": "^1.5", "tracy/tracy": "^2.10" }, diff --git a/examples/index.php b/examples/index.php index 6e5bf4d..272c3da 100644 --- a/examples/index.php +++ b/examples/index.php @@ -2,24 +2,35 @@ require_once __DIR__ . '/../vendor/autoload.php'; -use h4kuna\CriticalCache; -use h4kuna\Exchange; -use h4kuna\Exchange\RatingList; - -$cacheFactory = new CriticalCache\CacheFactory('exchange'); - -$exchangeFactory = new Exchange\ExchangeFactory( - from: 'eur', - to: 'usd', - allowedCurrencies: [ - 'CZK', - 'USD', - 'eur', // lower case will be changed to upper case - ], - cacheFactory: $cacheFactory -); +use h4kuna\Exchange\Currency\Property; +use h4kuna\Exchange\Driver\Cnb\Day; +use h4kuna\Exchange\Exchange; +use h4kuna\Exchange\ExchangeFactory; +use h4kuna\Exchange\RatingList\CacheEntity; +use h4kuna\Exchange\RatingList\RatingList; + +{ # by factory + $exchangeFactory = new ExchangeFactory( + from: 'eur', + to: 'usd', + allowedCurrencies: [ + 'CZK', + 'USD', + 'eur', // lower case will be changed to upper case + ], + ); + + $exchange = $exchangeFactory->create(); +} -$exchange = $exchangeFactory->create(); +{ # custom RatingList + $ratingList = new RatingList(new DateTimeImmutable(), new DateTimeImmutable(), null, [ + 'EUR' => new Property(1, 25.0, 'EUR'), + 'USD' => new Property(1, 20.0, 'USD'), + 'CZK' => new Property(1, 1.0, 'CZK'), + ]); + $exchange = new Exchange('EUR', $ratingList, 'USD'); +} echo $exchange->change(100) . PHP_EOL; // EUR -> USD = 125.0 @@ -30,7 +41,7 @@ echo PHP_EOL; // History -$exchangePast = $exchange->modify(cacheEntity: new RatingList\CacheEntity(new \Datetime('2000-12-30'), Exchange\Driver\Cnb\Day::class)); +$exchangePast = $exchangeFactory->create(cacheEntity: new CacheEntity(new Datetime('2000-12-30'), new Day)); echo $exchangePast->change(100) . PHP_EOL; echo PHP_EOL; @@ -41,6 +52,6 @@ // Iterator foreach ($exchange as $code => $property) { - /* @var $property Exchange\Currency\Property */ + /* @var $property Property */ var_dump($code, $property); } 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..148e2fb --- /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 = [], + ) + { + } + + + 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 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(Utils::DateFormat); + } + + + 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..4743215 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); } - 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([ + 'filtered' => 'true', + 'date' => $date->format('Y-m-d'), + ]); + } + + + public function getTimeZone(): DateTimeZone + { + return $this->timeZone; + } + + + public function createSourceData(ResponseInterface $response): SourceData { $data = $response->getBody()->getContents(); $xml = simplexml_load_string($data); @@ -44,31 +65,30 @@ protected function createList(ResponseInterface $response): iterable $czk->addChild('exchangeRateCenter', '1'); $czk->addChild('currencyFrom', 'CZK'); $czk->addChild('exchangeRateSellCash', '1'); + $czk->addChild('exchangeRateSellCenter', '1'); + $czk->addChild('exchangeRateSell', '1'); $czk->addChild('exchangeRateBuyCash', '1'); + $czk->addChild('exchangeRateCenter', '1'); + $czk->addChild('exchangeRateBuy', '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..fe46eb1 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->exchangeRateBuy; } } 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..6e48aaa 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->exchangeRateSell; } } 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 + * @implements IteratorAggregate + * @implements ArrayAccess + * properties become readonly */ -class Exchange implements \IteratorAggregate, \ArrayAccess +class Exchange implements IteratorAggregate, ArrayAccess { private Property $from; @@ -21,153 +22,83 @@ 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); - } - } - - - public function getFrom(): Property - { - return $this->from; - } - - - public function modify(?string $to = null, ?string $from = null, ?CacheEntity $cacheEntity = null): static - { - $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; + $this->from = $this->get($from); + $this->to = $to === null ? $this->from : $this->get($to); } /** - * @deprecated use getFrom() + * @throws UnknownCurrencyException */ - public function getDefault(): Property + public function get(string $code): Property { - return $this->getFrom(); - } - - - /** - * @deprecated use getTo() - */ - public function getOutput(): Property - { - return $this->getTo(); + return $this->ratingList->getSafe($code); } /** * Transfer number by exchange rate. */ - public function change(float $price, ?string $from = null, ?string $to = null): float + public function change(float|int|null $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]; + if ($price == 0) { // intentionally 0, 0.0, null + return .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 (float) $price; } - /** - * @return Generator - */ - public function getIterator(): Generator + public function getFrom(?string $from = null): Property { - foreach ($this->ratingList->all() as $code => $exists) { - yield $code => $this->ratingList->get($code); - } + return $from === null ? $this->from : $this->ratingList->get($from); } - public function offsetExists(mixed $offset): bool + public function getTo(?string $to = null): Property { - return isset($this->ratingList->all()[$offset]); + return $to === null ? $this->to : $this->ratingList->get($to); } - public function offsetGet(mixed $offset): Property + public function getIterator(): RatingListInterface { - return $this->ratingList->get($offset); - } - - - public function offsetSet(mixed $offset, mixed $value): void - { - throw new FrozenMethodException('not supported'); - } - - - public function offsetUnset(mixed $offset): void - { - throw new FrozenMethodException('not supported'); + return $this->ratingList; } - /** - * @deprecated method will remove - */ - public function getRatingList(): RatingList\RatingList + public function offsetExists(mixed $offset): bool { - assert($this->ratingList instanceof RatingList\RatingList); - return $this->ratingList; + return $this->ratingList->offsetExists($offset); } - public function getDate(): DateTimeImmutable + public function offsetGet(mixed $offset): Property { - return $this->ratingList->getDate(); + return $this->get($offset); } - protected function setFrom(string|Property $from): void + public function offsetSet(mixed $offset, mixed $value): void { - $this->from = $from instanceof Property ? $from : $this->ratingList->get(strtoupper($from)); + $this->ratingList->offsetSet($offset, $value); } - public function setTo(string|Property $to): void + public function offsetUnset(mixed $offset): 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..acd6792 100644 --- a/src/ExchangeFactory.php +++ b/src/ExchangeFactory.php @@ -2,69 +2,78 @@ namespace h4kuna\Exchange; -use DateTimeInterface; +use GuzzleHttp\Client; +use GuzzleHttp\Psr7\HttpFactory; use h4kuna\CriticalCache\CacheFactory; -use h4kuna\Exchange\Driver; +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, + ?RatingListCache $ratingListCache = null, array $allowedCurrencies = [], - ?Driver\DriverBuilderFactory $driverBuilderFactory = null, - ?CacheFactory $cacheFactory = null, - private string $driver = Driver\Cnb\Day::class, ) { - $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 + /** + * @param array $allowedCurrencies + */ + private static function createRatingListCache(array $allowedCurrencies): RatingListCache { - $cache = $this->createRatingListCache(); + return new RatingListCache( + self::createCacheFactory()->create(), + new SourceDownload(self::createClient(), self::createRequestFactory(), $allowedCurrencies), + ); + } + + public function create( + ?string $from = null, + ?string $to = null, + ?CacheEntity $cacheEntity = null, + ): Exchange + { 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 + 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')) { - $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 + public function __construct(?DateTimeInterface $date = null, ?Source $source = null) { - return self::joinKey($this->cacheKey, $code); + $this->source = $source ?? new Day(); + $this->date = $date !== null && Utils::isTodayAndFuture($date, $this->source->getTimeZone()) ? null : $date; + + $cacheKey = self::makeCacheKey($this->source, $this->date); + $this->cacheKeyTtl = self::joinKey($cacheKey, 'ttl'); + $this->cacheKeyAll = self::joinKey($cacheKey, 'all.v7.1'); } - private function makeCacheKey(): string + private static function makeCacheKey(Source $source, ?DateTimeInterface $date): string { - $key = $this->date === null ? '' : $this->date->format('.Y-m-d'); - return str_replace('\\', '.', $this->driver) . $key; + $key = $date === null ? '' : $date->format('.' . Utils::DateFormat); + return str_replace('\\', '.', $source::class) . $key; } diff --git a/src/RatingList/RatingList.php b/src/RatingList/RatingList.php index d67bc4e..ce05fd8 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; +/** + * Serializable, remember if you want to rename! + */ 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 offsetGet(mixed $offset): Property { - if ($this->all === null) { - $this->getDate(); // init cache - $this->all = $this->ratingListCache->all($this->cacheEntity); - } - - return $this->all; + return $this->get($offset); } - public function getDate(): DateTimeImmutable + public function get(string $code): Property { - if ($this->date === null) { - [ - 'date' => $this->date, - 'expire' => $this->expire, - ] = $this->ratingListCache->build($this->cacheEntity); - } - return $this->date; + // no check if exist for fast + return $this->properties[$code]; } - 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 getSafe(string $code): Property { - return isset($this->all()[$offset]); + $code = strtoupper($code); + if ($this->offsetExists($code) === false) { + throw new UnknownCurrencyException($code === '' ? '[empty string]' : $code); + } + + return $this->get($code); } - /** - * @deprecated moved to class Exchange - */ - public function offsetGet(mixed $offset): Property + public function offsetExists(mixed $offset): bool { - return $this->get($offset); + return isset($this->properties[$offset]); } - /** - * @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..6377167 100644 --- a/src/RatingList/RatingListCache.php +++ b/src/RatingList/RatingListCache.php @@ -2,159 +2,97 @@ 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 self::toDate($ratingList); }); - } - - - /** - * @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); - } + if ($ratingList === null) { + $ratingList = $this->cache->get($cacheEntity->cacheKeyAll); - 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 - */ - public function all(CacheEntity $cacheEntity): array - { - return $this->cache->load($cacheEntity->cacheKeyAll, static fn ( - ) => throw new InvalidStateException('Call build() first.')); - } - - - /** - * @return array{date: DateTimeImmutable, expire: ?DateTimeImmutable, ttl: ?int} - * + * @return array{RatingListInterface, ?int} * @throws ClientExceptionInterface */ 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 instanceof RatingListInterface) === false) { + throw $e; } - throw $e; + $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 + * @throws ClientExceptionInterface */ - private static function makeCacheData( - DateTimeImmutable $date, - ?DateTimeImmutable $expire = null, - ?int $ttl = null - ): array + public function rebuild(CacheEntity $cacheEntity): bool { - return [ - 'date' => $date, - 'expire' => $expire, - 'ttl' => $ttl, - ]; + $oldValue = $this->cache->get($cacheEntity->cacheKeyTtl); + [$ratingList, $ttl] = $this->buildCache($cacheEntity, $this->cache, ''); + $value = self::toDate($ratingList); + $this->cache->set($cacheEntity->cacheKeyTtl, $value, $ttl); + + return $oldValue !== $value; } - /** - * @return cacheType - */ - private static function countCacheData(Driver $provider, int $floatTtl): array + private static function toDate(RatingListInterface $ratingList): string { - $expire = $provider->getRefresh(); - $ttl = Utils::countTTL($expire, $floatTtl); - - return self::makeCacheData($provider->getDate(), DateTimeImmutable::createFromMutable($expire), $ttl); + return $ratingList->getDate()->format(DATE_RFC3339); } } diff --git a/src/RatingList/RatingListInterface.php b/src/RatingList/RatingListInterface.php index 15e3c89..08a4b98 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, then error undefined index */ - function modify(CacheEntity $cacheEntity): self; - - function get(string $code): Property; /** - * @return array + * @throws UnknownCurrencyException */ - function all(): array; + function getSafe(string $code): Property; + + + function getRequest(): ?DateTimeImmutable; function getDate(): DateTimeImmutable; - function getExpire(): ?DateTimeImmutable; + function getExpire(): ?DateTime; } diff --git a/src/Utils.php b/src/Utils.php index b575197..9f1d9f0 100644 --- a/src/Utils.php +++ b/src/Utils.php @@ -3,20 +3,84 @@ namespace h4kuna\Exchange; use DateTime; -use h4kuna\DataType\Basic\Strings; +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; +use h4kuna\Exchange\Exceptions\InvalidStateException; use Nette\StaticClass; +use Nette\Utils\DateTime as NetteDateTime; final class Utils { use StaticClass; + public const DateFormat = 'Y-m-d'; + + public const CacheMinutes = NetteDateTime::MINUTE * 30; + + /** * Stroke replace by point */ public static function stroke2point(string $str): string { - return trim(Strings::strokeToPoint($str)); + return trim(strtr($str, [',' => '.'])); + } + + + 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(self::DateFormat) >= (self::now($timeZone))->format(self::DateFormat); + } + + + 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; } 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..98bc27d --- /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: 25.6894262, + code: 'EUR', + ), + 'USD' => new Property( + foreign: 1, + home: 23.7399333, + 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.9567738, + code: 'EUR', + ), + 'USD' => new Property( + foreign: 1, + home: 22.1387667, + 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..5784097 --- /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.v7.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.v7.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.v7.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();