diff --git a/README.md b/README.md index 6493d4e..f554167 100755 --- a/README.md +++ b/README.md @@ -372,6 +372,7 @@ The following drivers are available with the package: | API Service | Driver name | API URL | |-------------------------|---------------------------|---------------------------------------------------------| +| CurrencyBeacon | `currency-beacon` | https://currencybeacon.com/ | | Exchange Rates API IO | `exchange-rates-api-io` | https://exchangeratesapi.io/ | | Exchange Rates Data API | `exchange-rates-data-api` | https://apilayer.com/marketplace/exchangerates_data-api | | Exchange Rate Host | `exchange-rate-host` | https://exchangerate.host | diff --git a/src/Classes/ExchangeRate.php b/src/Classes/ExchangeRate.php index 8ebb97e..ba89f0e 100755 --- a/src/Classes/ExchangeRate.php +++ b/src/Classes/ExchangeRate.php @@ -4,6 +4,7 @@ namespace AshAllenDesign\LaravelExchangeRates\Classes; +use AshAllenDesign\LaravelExchangeRates\Drivers\CurrencyBeacon\CurrencyBeaconDriver; use AshAllenDesign\LaravelExchangeRates\Drivers\ExchangeRateHost\ExchangeRateHostDriver; use AshAllenDesign\LaravelExchangeRates\Drivers\ExchangeRatesApiIo\ExchangeRatesApiIoDriver; use AshAllenDesign\LaravelExchangeRates\Drivers\ExchangeRatesDataApi\ExchangeRatesDataApiDriver; @@ -12,6 +13,11 @@ class ExchangeRate extends Manager { + public function createCurrencyBeaconDriver(): ExchangeRateDriver + { + return new CurrencyBeaconDriver(); + } + public function createExchangeRatesDataApiDriver(): ExchangeRateDriver { return new ExchangeRatesDataApiDriver(); diff --git a/src/Drivers/CurrencyBeacon/CurrencyBeaconDriver.php b/src/Drivers/CurrencyBeacon/CurrencyBeaconDriver.php new file mode 100755 index 0000000..b5c1cc0 --- /dev/null +++ b/src/Drivers/CurrencyBeacon/CurrencyBeaconDriver.php @@ -0,0 +1,218 @@ +cacheRepository = $cacheRepository ?? new CacheRepository(); + + $this->sharedDriverLogicHandler = new SharedDriverLogicHandler( + $requestBuilder, + $this->cacheRepository, + ); + } + + /** + * {@inheritDoc} + */ + public function currencies(): array + { + $cacheKey = 'currencies'; + + if ($cachedExchangeRate = $this->sharedDriverLogicHandler->attemptToResolveFromCache($cacheKey)) { + return $cachedExchangeRate; + } + + $response = $this->sharedDriverLogicHandler + ->getRequestBuilder() + ->makeRequest('/currencies', ['type' => 'fiat']); + + /** @var array> $currenciesFromResponse */ + $currenciesFromResponse = $response->get('response'); + + $currencies = collect($currenciesFromResponse)->pluck('short_code')->toArray(); + + $this->sharedDriverLogicHandler->attemptToStoreInCache($cacheKey, $currencies); + + return $currencies; + } + + /** + * {@inheritDoc} + */ + public function exchangeRate(string $from, array|string $to, ?Carbon $date = null): float|array + { + $this->sharedDriverLogicHandler->validateExchangeRateInput($from, $to, $date); + + if ($from === $to) { + return 1.0; + } + + $cacheKey = $this->cacheRepository->buildCacheKey($from, $to, $date ?? Carbon::now()); + + if ($cachedExchangeRate = $this->sharedDriverLogicHandler->attemptToResolveFromCache($cacheKey)) { + // If the exchange rate has been retrieved from the cache as a + // string (e.g. "1.23"), then cast it to a float (e.g. 1.23). + // If we have retrieved the rates for many currencies, it + // will be an array of floats, so just return it. + return is_string($cachedExchangeRate) + ? (float) $cachedExchangeRate + : $cachedExchangeRate; + } + + $symbols = is_string($to) ? $to : implode(',', $to); + $queryParams = ['base' => $from, 'symbols' => $symbols]; + + if ($date) { + $queryParams['date'] = $date->format('Y-m-d'); + } + + $url = $date ? '/historical' : '/latest'; + + /** @var array $response */ + $response = $this->sharedDriverLogicHandler + ->getRequestBuilder() + ->makeRequest($url, $queryParams) + ->rates(); + + $exchangeRate = is_string($to) + ? $response[$to] + : $response; + + $this->sharedDriverLogicHandler->attemptToStoreInCache($cacheKey, $exchangeRate); + + return $exchangeRate; + } + + /** + * {@inheritDoc} + */ + public function exchangeRateBetweenDateRange( + string $from, + array|string $to, + Carbon $date, + Carbon $endDate + ): array { + $this->sharedDriverLogicHandler->validateExchangeRateBetweenDateRangeInput($from, $to, $date, $endDate); + + $cacheKey = $this->cacheRepository->buildCacheKey($from, $to, $date, $endDate); + + if ($cachedExchangeRate = $this->sharedDriverLogicHandler->attemptToResolveFromCache($cacheKey)) { + return $cachedExchangeRate; + } + + $conversions = $from === $to + ? $this->sharedDriverLogicHandler->exchangeRateDateRangeResultWithSameCurrency($date, $endDate) + : $this->makeRequestForExchangeRates($from, $to, $date, $endDate); + + $this->sharedDriverLogicHandler->attemptToStoreInCache($cacheKey, $conversions); + + return $conversions; + } + + /** + * {@inheritDoc} + */ + public function convert(int $value, string $from, array|string $to, ?Carbon $date = null): float|array + { + return $this->sharedDriverLogicHandler->convertUsingRates( + $this->exchangeRate($from, $to, $date), + $to, + $value, + ); + } + + /** + * {@inheritDoc} + */ + public function convertBetweenDateRange( + int $value, + string $from, + array|string $to, + Carbon $date, + Carbon $endDate + ): array { + return $this->sharedDriverLogicHandler->convertUsingRatesForDateRange( + $this->exchangeRateBetweenDateRange($from, $to, $date, $endDate), + $to, + $value, + ); + } + + /** + * {@inheritDoc} + */ + public function shouldCache(bool $shouldCache = true): ExchangeRateDriver + { + $this->sharedDriverLogicHandler->shouldCache($shouldCache); + + return $this; + } + + /** + * {@inheritDoc} + */ + public function shouldBustCache(bool $bustCache = true): ExchangeRateDriver + { + $this->sharedDriverLogicHandler->shouldBustCache($bustCache); + + return $this; + } + + /** + * Make a request to the exchange rates API to get the exchange rates between a + * date range. If only one currency is being used, we flatten the array to + * remove currency symbol before returning it. + * + * @param string|string[] $to + * @return array|array> + * + * @throws RequestException + */ + private function makeRequestForExchangeRates(string $from, string|array $to, Carbon $date, Carbon $endDate): array + { + $symbols = is_string($to) ? $to : implode(',', $to); + + /** @var Response $result */ + $result = $this->sharedDriverLogicHandler + ->getRequestBuilder() + ->makeRequest('/timeseries', [ + 'base' => $from, + 'start_date' => $date->format('Y-m-d'), + 'end_date' => $endDate->format('Y-m-d'), + 'symbols' => $symbols, + ]); + + $conversions = $result->timeSeries(); + + foreach ($conversions as $rateDate => $rates) { + $ratesForDay = is_string($to) + ? $rates[$to] + : $rates; + + $conversions[$rateDate] = $ratesForDay; + } + + ksort($conversions); + + return $conversions; + } +} diff --git a/src/Drivers/CurrencyBeacon/RequestBuilder.php b/src/Drivers/CurrencyBeacon/RequestBuilder.php new file mode 100644 index 0000000..4497bf8 --- /dev/null +++ b/src/Drivers/CurrencyBeacon/RequestBuilder.php @@ -0,0 +1,42 @@ +apiKey = config('laravel-exchange-rates.api_key'); + } + + /** + * Make an API request to the CurrencyBeacon API. + * + * @param array $queryParams + * + * @throws RequestException + */ + public function makeRequest(string $path, array $queryParams = []): ResponseContract + { + $protocol = config('laravel-exchange-rates.https') ? 'https://' : 'http://'; + + $rawResponse = Http::baseUrl($protocol.self::BASE_URL) + ->get( + $path, + array_merge(['api_key' => $this->apiKey], $queryParams) + ) + ->throw() + ->json(); + + return new Response($rawResponse); + } +} diff --git a/src/Drivers/CurrencyBeacon/Response.php b/src/Drivers/CurrencyBeacon/Response.php new file mode 100644 index 0000000..06ff3d4 --- /dev/null +++ b/src/Drivers/CurrencyBeacon/Response.php @@ -0,0 +1,32 @@ +rawResponse, $key); + } + + public function rates(): array + { + return $this->get('response.rates'); + } + + public function timeSeries(): array + { + return $this->get('response'); + } + + public function raw(): mixed + { + return $this->rawResponse; + } +} diff --git a/tests/Unit/Classes/ExchangeRateTest.php b/tests/Unit/Classes/ExchangeRateTest.php index 0220273..e6d820d 100644 --- a/tests/Unit/Classes/ExchangeRateTest.php +++ b/tests/Unit/Classes/ExchangeRateTest.php @@ -5,6 +5,7 @@ namespace AshAllenDesign\LaravelExchangeRates\Tests\Unit\Classes; use AshAllenDesign\LaravelExchangeRates\Classes\ExchangeRate; +use AshAllenDesign\LaravelExchangeRates\Drivers\CurrencyBeacon\CurrencyBeaconDriver; use AshAllenDesign\LaravelExchangeRates\Drivers\ExchangeRateHost\ExchangeRateHostDriver; use AshAllenDesign\LaravelExchangeRates\Drivers\ExchangeRatesApiIo\ExchangeRatesApiIoDriver; use AshAllenDesign\LaravelExchangeRates\Drivers\ExchangeRatesDataApi\ExchangeRatesDataApiDriver; @@ -49,6 +50,7 @@ public function validDriversProvider(): array ['exchange-rates-api-io', ExchangeRatesApiIoDriver::class], ['exchange-rates-data-api', ExchangeRatesDataApiDriver::class], ['exchange-rate-host', ExchangeRateHostDriver::class], + ['currency-beacon', CurrencyBeaconDriver::class], ]; } } diff --git a/tests/Unit/Drivers/CurrencyBeacon/ConvertBetweenDateRangeTest.php b/tests/Unit/Drivers/CurrencyBeacon/ConvertBetweenDateRangeTest.php new file mode 100644 index 0000000..eed09fd --- /dev/null +++ b/tests/Unit/Drivers/CurrencyBeacon/ConvertBetweenDateRangeTest.php @@ -0,0 +1,330 @@ +subWeek(); + $toDate = now(); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class)->makePartial(); + $requestBuilderMock->expects('makeRequest') + ->withArgs([ + '/timeseries', + [ + 'base' => 'GBP', + 'start_date' => $fromDate->format('Y-m-d'), + 'end_date' => $toDate->format('Y-m-d'), + 'symbols' => 'EUR', + ], + ]) + ->once() + ->andReturn($this->mockResponseForOneSymbol()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $currencies = $exchangeRate->convertBetweenDateRange(100, 'GBP', 'EUR', $fromDate, $toDate); + + $this->assertEqualsWithDelta([ + '2019-11-08' => 116.06583254, + '2019-11-06' => 116.23446817, + '2019-11-07' => 115.68450522, + '2019-11-05' => 116.12648497, + '2019-11-04' => 115.78362356, + ], $currencies, self::FLOAT_DELTA); + + $cachedExchangeRates = [ + '2019-11-08' => 1.1606583254, + '2019-11-06' => 1.1623446817, + '2019-11-07' => 1.1568450522, + '2019-11-05' => 1.1612648497, + '2019-11-04' => 1.1578362356, + ]; + $this->assertEquals($cachedExchangeRates, + Cache::get('laravel_xr_GBP_EUR_'.$fromDate->format('Y-m-d').'_'.$toDate->format('Y-m-d'))); + } + + /** @test */ + public function cached_exchange_rates_are_used_if_they_exist(): void + { + $fromDate = now()->subWeek(); + $toDate = now(); + + $cacheKey = 'laravel_xr_GBP_EUR_'.$fromDate->format('Y-m-d').'_'.$toDate->format('Y-m-d'); + $cachedValues = $expectedArray = [ + '2019-11-08' => 0.111, + '2019-11-06' => 0.222, + '2019-11-07' => 0.333, + '2019-11-05' => 0.444, + '2019-11-04' => 0.555, + ]; + Cache::forever($cacheKey, $cachedValues); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class)->makePartial(); + $requestBuilderMock->expects('makeRequest')->never(); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $currencies = $exchangeRate->convertBetweenDateRange(100, 'GBP', 'EUR', $fromDate, $toDate); + + $this->assertEqualsWithDelta([ + '2019-11-08' => 11.1, + '2019-11-06' => 22.2, + '2019-11-07' => 33.3, + '2019-11-05' => 44.4, + '2019-11-04' => 55.5, + ], $currencies, self::FLOAT_DELTA); + + $this->assertEquals($expectedArray, + Cache::get('laravel_xr_GBP_EUR_'.$fromDate->format('Y-m-d').'_'.$toDate->format('Y-m-d'))); + } + + /** @test */ + public function cached_exchange_rate_is_ignored_if_should_bust_cache_method_is_used(): void + { + $fromDate = now()->subWeek(); + $toDate = now(); + + $cacheKey = 'laravel_xr_GBP_EUR_'.$fromDate->format('Y-m-d').'_'.$toDate->format('Y-m-d'); + $cachedValues = $expectedArray = [ + '2019-11-08' => 0.111, + '2019-11-06' => 0.222, + '2019-11-07' => 0.333, + '2019-11-05' => 0.444, + '2019-11-04' => 0.555, + ]; + Cache::forever($cacheKey, $cachedValues); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class)->makePartial(); + $requestBuilderMock->expects('makeRequest') + ->withArgs([ + '/timeseries', + [ + 'base' => 'GBP', + 'start_date' => $fromDate->format('Y-m-d'), + 'end_date' => $toDate->format('Y-m-d'), + 'symbols' => 'EUR', + ], + ]) + ->once() + ->andReturn($this->mockResponseForOneSymbol()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $currencies = $exchangeRate->shouldBustCache()->convertBetweenDateRange(100, 'GBP', 'EUR', $fromDate, $toDate); + + $this->assertEqualsWithDelta([ + '2019-11-08' => 116.06583254, + '2019-11-06' => 116.23446817, + '2019-11-07' => 115.68450522, + '2019-11-05' => 116.12648497, + '2019-11-04' => 115.78362356, + ], $currencies, self::FLOAT_DELTA); + + $cachedExchangeRates = [ + '2019-11-08' => 1.1606583254, + '2019-11-06' => 1.1623446817, + '2019-11-07' => 1.1568450522, + '2019-11-05' => 1.1612648497, + '2019-11-04' => 1.1578362356, + ]; + $this->assertEquals($cachedExchangeRates, + Cache::get('laravel_xr_GBP_EUR_'.$fromDate->format('Y-m-d').'_'.$toDate->format('Y-m-d'))); + } + + /** @test */ + public function converted_values_can_be_returned_for_multiple_currencies(): void + { + $fromDate = now()->subWeek(); + $toDate = now(); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class)->makePartial(); + $requestBuilderMock->expects('makeRequest') + ->withArgs([ + '/timeseries', + [ + 'base' => 'GBP', + 'start_date' => $fromDate->format('Y-m-d'), + 'end_date' => $toDate->format('Y-m-d'), + 'symbols' => 'EUR,USD', + ], + ]) + ->once() + ->andReturn($this->mockResponseForMultipleSymbols()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $currencies = $exchangeRate->convertBetweenDateRange(100, 'GBP', ['EUR', 'USD'], $fromDate, $toDate); + + $expectedArray = [ + '2019-11-08' => ['EUR' => 116.06583254, 'USD' => 111.11111111], + '2019-11-06' => ['EUR' => 116.23446817, 'USD' => 122.22222222], + '2019-11-07' => ['EUR' => 115.68450522, 'USD' => 133.33333333], + '2019-11-05' => ['EUR' => 116.12648497, 'USD' => 144.44444444], + '2019-11-04' => ['EUR' => 115.78362356, 'USD' => 155.55555555], + ]; + + $this->assertEqualsWithDelta($expectedArray, $currencies, self::FLOAT_DELTA); + + $cachedExchangeRates = [ + '2019-11-08' => ['EUR' => 1.1606583254, 'USD' => 1.1111111111], + '2019-11-06' => ['EUR' => 1.1623446817, 'USD' => 1.2222222222], + '2019-11-07' => ['EUR' => 1.1568450522, 'USD' => 1.3333333333], + '2019-11-05' => ['EUR' => 1.1612648497, 'USD' => 1.4444444444], + '2019-11-04' => ['EUR' => 1.1578362356, 'USD' => 1.5555555555], + ]; + $this->assertEquals($cachedExchangeRates, + Cache::get('laravel_xr_GBP_EUR_USD_'.$fromDate->format('Y-m-d').'_'.$toDate->format('Y-m-d'))); + } + + /** @test */ + public function request_is_not_made_if_the_currencies_are_the_same(): void + { + $fromDate = Carbon::createFromDate(2019, 11, 4); + $toDate = Carbon::createFromDate(2019, 11, 10); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class)->makePartial(); + $requestBuilderMock->expects('makeRequest')->withAnyArgs()->never(); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $currencies = $exchangeRate->convertBetweenDateRange(100, 'EUR', 'EUR', $fromDate, $toDate); + + $this->assertEquals([ + '2019-11-08' => 100.0, + '2019-11-06' => 100.0, + '2019-11-07' => 100.0, + '2019-11-05' => 100.0, + '2019-11-04' => 100.0, + ], $currencies); + + $cachedExchangeRates = [ + '2019-11-08' => 1.0, + '2019-11-06' => 1.0, + '2019-11-07' => 1.0, + '2019-11-05' => 1.0, + '2019-11-04' => 1.0, + ]; + $this->assertEquals($cachedExchangeRates, + Cache::get('laravel_xr_EUR_EUR_'.$fromDate->format('Y-m-d').'_'.$toDate->format('Y-m-d'))); + } + + /** @test */ + public function exception_is_thrown_if_the_date_parameter_passed_is_in_the_future(): void + { + $this->expectException(InvalidDateException::class); + $this->expectExceptionMessage('The date must be in the past.'); + + $exchangeRate = new CurrencyBeaconDriver(); + $exchangeRate->convertBetweenDateRange(100, 'EUR', 'GBP', now()->addMinute(), now()->subDay()); + } + + /** @test */ + public function exception_is_thrown_if_the_end_date_parameter_passed_is_in_the_future(): void + { + $this->expectException(InvalidDateException::class); + $this->expectExceptionMessage('The date must be in the past.'); + + $exchangeRate = new CurrencyBeaconDriver(); + $exchangeRate->convertBetweenDateRange(100, 'EUR', 'GBP', now()->subDay(), now()->addMinute()); + } + + /** @test */ + public function exception_is_thrown_if_the_end_date_is_before_the_start_date(): void + { + $this->expectException(InvalidDateException::class); + $this->expectExceptionMessage("The 'from' date must be before the 'to' date."); + + $exchangeRate = new CurrencyBeaconDriver(); + $exchangeRate->convertBetweenDateRange(100, 'EUR', 'GBP', now()->subDay(), now()->subWeek()); + } + + /** @test */ + public function exception_is_thrown_if_the_from_parameter_is_invalid(): void + { + $this->expectException(InvalidCurrencyException::class); + $this->expectExceptionMessage('INVALID is not a valid currency code.'); + + $exchangeRate = new CurrencyBeaconDriver(); + $exchangeRate->convertBetweenDateRange(100, 'INVALID', 'GBP', now()->subWeek(), now()->subDay()); + } + + /** @test */ + public function exception_is_thrown_if_the_to_parameter_is_invalid(): void + { + $this->expectException(InvalidCurrencyException::class); + $this->expectExceptionMessage('INVALID is not a valid currency code.'); + + $exchangeRate = new CurrencyBeaconDriver(); + $exchangeRate->convertBetweenDateRange(100, 'GBP', 'INVALID', now()->subWeek(), now()->subDay()); + } + + private function mockResponseForOneSymbol(): Response + { + return new Response([ + 'meta' => [ + 'code' => 200, + 'disclaimer' => 'Usage subject to terms: https:\/\/currencybeacon.com\/terms', + ], + 'response' => [ + '2019-11-08' => [ + 'EUR' => 1.1606583254, + ], + '2019-11-06' => [ + 'EUR' => 1.1623446817, + ], + '2019-11-07' => [ + 'EUR' => 1.1568450522, + ], + '2019-11-05' => [ + 'EUR' => 1.1612648497, + ], + '2019-11-04' => [ + 'EUR' => 1.1578362356, + ], + ], + ]); + } + + private function mockResponseForMultipleSymbols(): Response + { + return new Response([ + 'meta' => [ + 'code' => 200, + 'disclaimer' => 'Usage subject to terms: https:\/\/currencybeacon.com\/terms', + ], + 'response' => [ + '2019-11-08' => [ + 'EUR' => 1.1606583254, + 'USD' => 1.1111111111, + ], + '2019-11-06' => [ + 'EUR' => 1.1623446817, + 'USD' => 1.2222222222, + ], + '2019-11-07' => [ + 'EUR' => 1.1568450522, + 'USD' => 1.3333333333, + ], + '2019-11-05' => [ + 'EUR' => 1.1612648497, + 'USD' => 1.4444444444, + ], + '2019-11-04' => [ + 'EUR' => 1.1578362356, + 'USD' => 1.5555555555, + ], + ], + ]); + } +} diff --git a/tests/Unit/Drivers/CurrencyBeacon/ConvertTest.php b/tests/Unit/Drivers/CurrencyBeacon/ConvertTest.php new file mode 100644 index 0000000..7aee8c9 --- /dev/null +++ b/tests/Unit/Drivers/CurrencyBeacon/ConvertTest.php @@ -0,0 +1,212 @@ +expects('makeRequest') + ->withArgs(['/latest', ['base' => 'EUR', 'symbols' => 'GBP']]) + ->once() + ->andReturn($this->mockResponseForCurrentDateAndOneSymbol()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $rate = $exchangeRate->convert(100, 'EUR', 'GBP'); + $this->assertEquals('86.158', $rate); + $this->assertEquals('0.86158', Cache::get('laravel_xr_EUR_GBP_'.now()->format('Y-m-d'))); + } + + /** @test */ + public function converted_value_in_the_past_is_returned_if_date_parameter_passed_and_rate_is_not_cached(): void + { + $mockDate = now(); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class); + $requestBuilderMock->expects('makeRequest') + ->withArgs([ + '/historical', + [ + 'base' => 'EUR', + 'symbols' => 'GBP', + 'date' => $mockDate->format('Y-m-d'), + ], + ]) + ->once() + ->andReturn($this->mockResponseForPastDateAndOneSymbol()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $rate = $exchangeRate->convert(100, 'EUR', 'GBP', $mockDate); + $this->assertEquals('87.053', $rate); + $this->assertEquals('0.87053', Cache::get('laravel_xr_EUR_GBP_'.$mockDate->format('Y-m-d'))); + } + + /** @test */ + public function cached_exchange_rate_is_used_if_it_exists(): void + { + $mockDate = now(); + + Cache::forever('laravel_xr_EUR_GBP_'.$mockDate->format('Y-m-d'), 0.123456); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class); + $requestBuilderMock->expects('makeRequest')->never(); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $rate = $exchangeRate->convert(100, 'EUR', 'GBP', $mockDate); + $this->assertEquals('12.3456', $rate); + $this->assertEquals('0.123456', Cache::get('laravel_xr_EUR_GBP_'.$mockDate->format('Y-m-d'))); + } + + /** @test */ + public function cached_exchange_rate_is_not_used_if_should_bust_cache_method_is_called(): void + { + $mockDate = now(); + + Cache::forever('laravel_xr_EUR_GBP_'.$mockDate->format('Y-m-d'), '0.123456'); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class); + $requestBuilderMock->expects('makeRequest') + ->withArgs([ + '/historical', + [ + 'base' => 'EUR', + 'symbols' => 'GBP', + 'date' => $mockDate->format('Y-m-d'), + ], + ]) + ->once() + ->andReturn($this->mockResponseForPastDateAndOneSymbol()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $rate = $exchangeRate->shouldBustCache()->convert(100, 'EUR', 'GBP', $mockDate); + $this->assertEquals('87.053', $rate); + $this->assertEquals('0.87053', Cache::get('laravel_xr_EUR_GBP_'.$mockDate->format('Y-m-d'))); + } + + /** @test */ + public function request_is_not_made_if_the_currencies_are_the_same(): void + { + $requestBuilderMock = Mockery::mock(RequestBuilder::class); + $requestBuilderMock->expects('makeRequest')->withAnyArgs()->never(); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $rate = $exchangeRate->convert(100, 'EUR', 'EUR'); + $this->assertEquals('100', $rate); + } + + /** @test */ + public function converted_values_are_returned_for_today_with_multiple_currencies(): void + { + $requestBuilderMock = Mockery::mock(RequestBuilder::class); + $requestBuilderMock->expects('makeRequest') + ->withArgs(['/latest', ['base' => 'EUR', 'symbols' => 'GBP,USD,CAD']]) + ->once() + ->andReturn($this->mockResponseForCurrentDateAndMultipleSymbols()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $rate = $exchangeRate->convert(100, 'EUR', ['GBP', 'USD', 'CAD']); + + $this->assertEqualsWithDelta(['CAD' => 145.61, 'USD' => 110.34, 'GBP' => 86.158], $rate, self::FLOAT_DELTA); + + $this->assertEquals( + ['CAD' => 1.4561, 'USD' => 1.1034, 'GBP' => 0.86158], + Cache::get('laravel_xr_EUR_CAD_GBP_USD_'.now()->format('Y-m-d')) + ); + } + + /** @test */ + public function exception_is_thrown_if_the_date_parameter_passed_is_in_the_future(): void + { + $this->expectException(InvalidDateException::class); + $this->expectExceptionMessage('The date must be in the past.'); + + $exchangeRate = new CurrencyBeaconDriver(); + $exchangeRate->convert(100, 'EUR', 'GBP', now()->addMinute()); + } + + /** @test */ + public function exception_is_thrown_if_the_from_parameter_is_invalid(): void + { + $this->expectException(InvalidCurrencyException::class); + $this->expectExceptionMessage('INVALID is not a valid currency code.'); + + $exchangeRate = new CurrencyBeaconDriver(); + $exchangeRate->convert(100, 'INVALID', 'GBP', now()->subMinute()); + } + + /** @test */ + public function exception_is_thrown_if_the_to_parameter_is_invalid(): void + { + $this->expectException(InvalidCurrencyException::class); + $this->expectExceptionMessage('INVALID is not a valid currency code.'); + + $exchangeRate = new CurrencyBeaconDriver(); + $exchangeRate->convert(100, 'GBP', 'INVALID', now()->subMinute()); + } + + private function mockResponseForCurrentDateAndOneSymbol(): Response + { + return new Response([ + 'meta' => [ + 'code' => 200, + 'disclaimer' => 'Usage subject to terms: https:\/\/currencybeacon.com\/terms', + ], + 'response' => [ + 'date' => '2024-02-07T10:00:07Z', + 'base' => 'EUR', + 'rates' => [ + 'GBP' => 0.86158, + ], + ], + ]); + } + + private function mockResponseForPastDateAndOneSymbol(): Response + { + return new Response([ + 'meta' => [ + 'code' => 200, + 'disclaimer' => 'Usage subject to terms: https:\/\/currencybeacon.com\/terms', + ], + 'response' => [ + 'date' => '2023-10-27T10:00:07Z', + 'base' => 'EUR', + 'rates' => [ + 'GBP' => 0.87053, + ], + ], + ]); + } + + private function mockResponseForCurrentDateAndMultipleSymbols(): Response + { + return new Response([ + 'meta' => [ + 'code' => 200, + 'disclaimer' => 'Usage subject to terms: https:\/\/currencybeacon.com\/terms', + ], + 'response' => [ + 'date' => '2024-02-07T10:00:07Z', + 'base' => 'EUR', + 'rates' => [ + 'CAD' => 1.4561, + 'USD' => 1.1034, + 'GBP' => 0.86158, + ], + ], + ]); + } +} diff --git a/tests/Unit/Drivers/CurrencyBeacon/CurrenciesTest.php b/tests/Unit/Drivers/CurrencyBeacon/CurrenciesTest.php new file mode 100644 index 0000000..ce7f747 --- /dev/null +++ b/tests/Unit/Drivers/CurrencyBeacon/CurrenciesTest.php @@ -0,0 +1,165 @@ +makePartial(); + $requestBuilderMock->expects('makeRequest') + ->withArgs(['/currencies', ['type' => 'fiat']]) + ->once() + ->andReturn($this->mockResponse()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $currencies = $exchangeRate->currencies(); + + $this->assertEquals($this->expectedResponse(), $currencies); + + $this->assertNotNull(Cache::get('laravel_xr_currencies')); + } + + /** @test */ + public function cached_currencies_are_returned_if_they_are_in_the_cache(): void + { + Cache::forever('laravel_xr_currencies', ['CUR1', 'CUR2', 'CUR3']); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class)->makePartial(); + $requestBuilderMock->expects('makeRequest')->never(); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $currencies = $exchangeRate->currencies(); + + $this->assertEquals(['CUR1', 'CUR2', 'CUR3'], $currencies); + } + + /** @test */ + public function currencies_are_fetched_if_the_currencies_are_cached_but_the_should_bust_cache_method_called(): void + { + Cache::forever('currencies', ['CUR1', 'CUR2', 'CUR3']); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class)->makePartial(); + $requestBuilderMock->expects('makeRequest') + ->withArgs(['/currencies', ['type' => 'fiat']]) + ->once() + ->andReturn($this->mockResponse()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $currencies = $exchangeRate->shouldBustCache()->currencies(); + + $this->assertEquals($this->expectedResponse(), $currencies); + } + + /** @test */ + public function currencies_are_not_cached_if_the_shouldCache_option_is_false(): void + { + $requestBuilderMock = Mockery::mock(RequestBuilder::class)->makePartial(); + $requestBuilderMock->expects('makeRequest') + ->withArgs(['/currencies', ['type' => 'fiat']]) + ->once() + ->andReturn($this->mockResponse()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $currencies = $exchangeRate->shouldCache(false)->currencies(); + + $this->assertEquals($this->expectedResponse(), $currencies); + + $this->assertNull(Cache::get('laravel_xr_currencies')); + } + + private function mockResponse(): Response + { + return new Response([ + 'meta' => [ + 'code' => 200, + 'disclaimer' => 'Usage subject to terms: https:\/\/currencybeacon.com\/terms', + ], + 'response' => [ + [ + 'id' => 1, + 'name' => 'UAE Dirham', + 'short_code' => 'AED', + 'code' => '784', + 'precision' => 2, + 'subunit' => 100, + 'symbol' => 'د.إ', + 'symbol_first' => true, + 'decimal_mark' => '.', + 'thousands_separator' => ',', + ], + [ + 'id' => 2, + 'name' => 'Afghani', + 'short_code' => 'AFN', + 'code' => '971', + 'precision' => 2, + 'subunit' => 100, + 'symbol' => '\u060b', + 'symbol_first' => false, + 'decimal_mark' => '.', + 'thousands_separator' => ',', + ], + [ + 'id' => 3, + 'name' => 'Lek', + 'short_code' => 'ALL', + 'code' => '8', + 'precision' => 2, + 'subunit' => 100, + 'symbol' => 'L', + 'symbol_first' => false, + 'decimal_mark' => '.', + 'thousands_separator' => ',', + ], + [ + 'id' => 4, + 'name' => 'Armenian Dram', + 'short_code' => 'AMD', + 'code' => '51', + 'precision' => 2, + 'subunit' => 100, + 'symbol' => '\u0564\u0580.', + 'symbol_first' => false, + 'decimal_mark' => '.', + 'thousands_separator' => ',', + ], + [ + 'id' => 5, + 'name' => 'Netherlands Antillean Guilder', + 'short_code' => 'ANG', + 'code' => '532', + 'precision' => 2, + 'subunit' => 100, + 'symbol' => '\u0192', + 'symbol_first' => true, + 'decimal_mark' => ',', + 'thousands_separator' => '.', + ], + + // further currencies omitted for brevity + ], + ]); + } + + private function expectedResponse(): array + { + return [ + 'AED', + 'AFN', + 'ALL', + 'AMD', + 'ANG', + ]; + } +} diff --git a/tests/Unit/Drivers/CurrencyBeacon/ExchangeRateBetweenDateRangeTest.php b/tests/Unit/Drivers/CurrencyBeacon/ExchangeRateBetweenDateRangeTest.php new file mode 100644 index 0000000..afdcf09 --- /dev/null +++ b/tests/Unit/Drivers/CurrencyBeacon/ExchangeRateBetweenDateRangeTest.php @@ -0,0 +1,344 @@ +subWeek(); + $toDate = now(); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class)->makePartial(); + $requestBuilderMock->expects('makeRequest') + ->withArgs([ + '/timeseries', + [ + 'base' => 'GBP', + 'start_date' => $fromDate->format('Y-m-d'), + 'end_date' => $toDate->format('Y-m-d'), + 'symbols' => 'EUR', + ], + ]) + ->once() + ->andReturn($this->mockResponseForOneSymbol()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $currencies = $exchangeRate->exchangeRateBetweenDateRange('GBP', 'EUR', $fromDate, $toDate); + + $expectedArray = [ + '2019-11-08' => 1.1606583254, + '2019-11-06' => 1.1623446817, + '2019-11-07' => 1.1568450522, + '2019-11-05' => 1.1612648497, + '2019-11-04' => 1.1578362356, + ]; + + $this->assertEquals($expectedArray, $currencies); + $this->assertEquals($expectedArray, + Cache::get('laravel_xr_GBP_EUR_'.$fromDate->format('Y-m-d').'_'.$toDate->format('Y-m-d'))); + } + + /** @test */ + public function cached_exchange_rates_are_returned_if_they_exist(): void + { + $fromDate = now()->subWeek(); + $toDate = now(); + + $cacheKey = 'laravel_xr_GBP_EUR_'.$fromDate->format('Y-m-d').'_'.$toDate->format('Y-m-d'); + $cachedValues = $expectedArray = [ + '2019-11-08' => 1, + '2019-11-06' => 2, + '2019-11-07' => 3, + '2019-11-05' => 4, + '2019-11-04' => 5, + ]; + Cache::forever($cacheKey, $cachedValues); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class)->makePartial(); + $requestBuilderMock->expects('makeRequest')->never(); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $currencies = $exchangeRate->exchangeRateBetweenDateRange('GBP', 'EUR', $fromDate, $toDate); + + $this->assertEquals($expectedArray, $currencies); + $this->assertEquals($expectedArray, + Cache::get('laravel_xr_GBP_EUR_'.$fromDate->format('Y-m-d').'_'.$toDate->format('Y-m-d'))); + } + + /** @test */ + public function cached_exchange_rates_are_ignored_if_should_bust_cache_method_is_called(): void + { + $fromDate = now()->subWeek(); + $toDate = now(); + + $cacheKey = 'GBP_EUR_'.$fromDate->format('Y-m-d').'_'.$toDate->format('Y-m-d'); + $cachedValues = [ + '2019-11-08' => 1, + '2019-11-06' => 2, + '2019-11-07' => 3, + '2019-11-05' => 4, + '2019-11-04' => 5, + ]; + Cache::forever($cacheKey, $cachedValues); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class)->makePartial(); + $requestBuilderMock->expects('makeRequest') + ->withArgs([ + '/timeseries', + [ + 'base' => 'GBP', + 'start_date' => $fromDate->format('Y-m-d'), + 'end_date' => $toDate->format('Y-m-d'), + 'symbols' => 'EUR', + ], + ]) + ->once() + ->andReturn($this->mockResponseForOneSymbol()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $currencies = $exchangeRate->shouldBustCache()->exchangeRateBetweenDateRange('GBP', 'EUR', $fromDate, $toDate); + + $expectedArray = [ + '2019-11-08' => 1.1606583254, + '2019-11-06' => 1.1623446817, + '2019-11-07' => 1.1568450522, + '2019-11-05' => 1.1612648497, + '2019-11-04' => 1.1578362356, + ]; + + $this->assertEquals($expectedArray, $currencies); + $this->assertEquals($expectedArray, + Cache::get('laravel_xr_GBP_EUR_'.$fromDate->format('Y-m-d').'_'.$toDate->format('Y-m-d'))); + } + + /** @test */ + public function exchange_rates_are_not_cached_if_the_shouldCache_option_is_false(): void + { + $fromDate = now()->subWeek(); + $toDate = now(); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class)->makePartial(); + $requestBuilderMock->expects('makeRequest') + ->withArgs([ + '/timeseries', + [ + 'base' => 'GBP', + 'start_date' => $fromDate->format('Y-m-d'), + 'end_date' => $toDate->format('Y-m-d'), + 'symbols' => 'EUR', + ], + ]) + ->once() + ->andReturn($this->mockResponseForOneSymbol()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $currencies = $exchangeRate->shouldCache(false)->exchangeRateBetweenDateRange('GBP', 'EUR', $fromDate, $toDate); + + $expectedArray = [ + '2019-11-08' => 1.1606583254, + '2019-11-06' => 1.1623446817, + '2019-11-07' => 1.1568450522, + '2019-11-05' => 1.1612648497, + '2019-11-04' => 1.1578362356, + ]; + + $this->assertEquals($expectedArray, $currencies); + $this->assertNull(Cache::get('laravel_xr_GBP_EUR_'.$fromDate->format('Y-m-d').'_'.$toDate->format('Y-m-d'))); + } + + /** @test */ + public function multiple_exchange_rates_between_date_range_are_returned_if_exchange_rates_are_not_cached(): void + { + $fromDate = now()->subWeek(); + $toDate = now(); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class)->makePartial(); + $requestBuilderMock->expects('makeRequest') + ->withArgs([ + '/timeseries', + [ + 'base' => 'GBP', + 'start_date' => $fromDate->format('Y-m-d'), + 'end_date' => $toDate->format('Y-m-d'), + 'symbols' => 'EUR,USD', + ], + ]) + ->once() + ->andReturn($this->mockResponseForMultipleSymbols()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $currencies = $exchangeRate->exchangeRateBetweenDateRange('GBP', ['EUR', 'USD'], $fromDate, $toDate); + + $expectedArray = [ + '2019-11-08' => ['EUR' => 1.1606583254, 'USD' => 1.1111111111], + '2019-11-06' => ['EUR' => 1.1623446817, 'USD' => 1.2222222222], + '2019-11-07' => ['EUR' => 1.1568450522, 'USD' => 1.3333333333], + '2019-11-05' => ['EUR' => 1.1612648497, 'USD' => 1.4444444444], + '2019-11-04' => ['EUR' => 1.1578362356, 'USD' => 1.5555555555], + ]; + + $this->assertEquals($expectedArray, $currencies); + $this->assertEquals($expectedArray, + Cache::get('laravel_xr_GBP_EUR_USD_'.$fromDate->format('Y-m-d').'_'.$toDate->format('Y-m-d')) + ); + } + + /** @test */ + public function request_is_not_made_if_the_currencies_are_the_same(): void + { + $fromDate = Carbon::createFromDate(2019, 11, 4); + $toDate = Carbon::createFromDate(2019, 11, 10); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class)->makePartial(); + $requestBuilderMock->expects('makeRequest')->withAnyArgs()->never(); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $currencies = $exchangeRate->exchangeRateBetweenDateRange('EUR', 'EUR', $fromDate, $toDate); + + $expectedArray = [ + '2019-11-08' => 1.0, + '2019-11-06' => 1.0, + '2019-11-07' => 1.0, + '2019-11-05' => 1.0, + '2019-11-04' => 1.0, + ]; + + $this->assertEquals($expectedArray, $currencies); + + $this->assertEquals($expectedArray, + Cache::get('laravel_xr_EUR_EUR_'.$fromDate->format('Y-m-d').'_'.$toDate->format('Y-m-d'))); + } + + /** @test */ + public function exception_is_thrown_if_the_date_parameter_passed_is_in_the_future(): void + { + $this->expectException(InvalidDateException::class); + $this->expectExceptionMessage('The date must be in the past.'); + + $exchangeRate = new CurrencyBeaconDriver(); + $exchangeRate->exchangeRateBetweenDateRange('EUR', 'GBP', now()->addMinute(), now()->subDay()); + } + + /** @test */ + public function exception_is_thrown_if_the_end_date_parameter_passed_is_in_the_future(): void + { + $this->expectException(InvalidDateException::class); + $this->expectExceptionMessage('The date must be in the past.'); + + $exchangeRate = new CurrencyBeaconDriver(); + $exchangeRate->exchangeRateBetweenDateRange('EUR', 'GBP', now()->subDay(), now()->addMinute()); + } + + /** @test */ + public function exception_is_thrown_if_the_end_date_is_before_the_start_date(): void + { + $this->expectException(InvalidDateException::class); + $this->expectExceptionMessage("The 'from' date must be before the 'to' date."); + + $exchangeRate = new CurrencyBeaconDriver(); + $exchangeRate->exchangeRateBetweenDateRange('EUR', 'GBP', now()->subDay(), now()->subWeek()); + } + + /** @test */ + public function exception_is_thrown_if_the_from_parameter_is_invalid(): void + { + $this->expectException(InvalidCurrencyException::class); + $this->expectExceptionMessage('INVALID is not a valid currency code.'); + + $exchangeRate = new CurrencyBeaconDriver(); + $exchangeRate->exchangeRateBetweenDateRange('INVALID', 'GBP', now()->subWeek(), now()->subDay()); + } + + /** @test */ + public function exception_is_thrown_if_the_to_parameter_is_invalid(): void + { + $this->expectException(InvalidCurrencyException::class); + $this->expectExceptionMessage('INVALID is not a valid currency code.'); + + $exchangeRate = new CurrencyBeaconDriver(); + $exchangeRate->exchangeRateBetweenDateRange('GBP', 'INVALID', now()->subWeek(), now()->subDay()); + } + + /** @test */ + public function exception_is_thrown_if_one_of_the_to_parameter_currencies_are_invalid(): void + { + $this->expectException(InvalidCurrencyException::class); + $this->expectExceptionMessage('INVALID is not a valid currency code.'); + + $exchangeRate = new CurrencyBeaconDriver(); + $exchangeRate->exchangeRateBetweenDateRange('GBP', ['USD', 'INVALID'], now()->subWeek(), now()->subDay()); + } + + private function mockResponseForOneSymbol(): Response + { + return new Response([ + 'meta' => [ + 'code' => 200, + 'disclaimer' => 'Usage subject to terms: https:\/\/currencybeacon.com\/terms', + ], + 'response' => [ + '2019-11-08' => [ + 'EUR' => 1.1606583254, + ], + '2019-11-06' => [ + 'EUR' => 1.1623446817, + ], + '2019-11-07' => [ + 'EUR' => 1.1568450522, + ], + '2019-11-05' => [ + 'EUR' => 1.1612648497, + ], + '2019-11-04' => [ + 'EUR' => 1.1578362356, + ], + ], + ]); + } + + private function mockResponseForMultipleSymbols(): Response + { + return new Response([ + 'meta' => [ + 'code' => 200, + 'disclaimer' => 'Usage subject to terms: https:\/\/currencybeacon.com\/terms', + ], + 'response' => [ + '2019-11-08' => [ + 'EUR' => 1.1606583254, + 'USD' => 1.1111111111, + ], + '2019-11-06' => [ + 'EUR' => 1.1623446817, + 'USD' => 1.2222222222, + ], + '2019-11-07' => [ + 'EUR' => 1.1568450522, + 'USD' => 1.3333333333, + ], + '2019-11-05' => [ + 'EUR' => 1.1612648497, + 'USD' => 1.4444444444, + ], + '2019-11-04' => [ + 'EUR' => 1.1578362356, + 'USD' => 1.5555555555, + ], + ], + ]); + } +} diff --git a/tests/Unit/Drivers/CurrencyBeacon/ExchangeRateTest.php b/tests/Unit/Drivers/CurrencyBeacon/ExchangeRateTest.php new file mode 100644 index 0000000..e639c88 --- /dev/null +++ b/tests/Unit/Drivers/CurrencyBeacon/ExchangeRateTest.php @@ -0,0 +1,301 @@ +expects('makeRequest') + ->withArgs(['/latest', ['base' => 'EUR', 'symbols' => 'GBP']]) + ->once() + ->andReturn($this->mockResponseForCurrentDateAndOneSymbol()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $rate = $exchangeRate->exchangeRate('EUR', 'GBP'); + $this->assertEquals('0.86158', $rate); + $this->assertEquals('0.86158', Cache::get('laravel_xr_EUR_GBP_'.now()->format('Y-m-d'))); + } + + /** @test */ + public function exchange_rate_in_the_past_is_returned_if_date_parameter_passed_and_rate_is_not_cached(): void + { + $mockDate = now(); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class); + $requestBuilderMock->expects('makeRequest') + ->withArgs(['/historical', [ + 'base' => 'EUR', + 'symbols' => 'GBP', + 'date' => $mockDate->format('Y-m-d'), + ]]) + ->once() + ->andReturn($this->mockResponseForPastDateAndOneSymbol()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $rate = $exchangeRate->exchangeRate('EUR', 'GBP', $mockDate); + $this->assertEquals('0.87053', $rate); + $this->assertEquals('0.87053', Cache::get('laravel_xr_EUR_GBP_'.$mockDate->format('Y-m-d'))); + } + + /** @test */ + public function cached_exchange_rate_is_returned_if_it_exists(): void + { + $mockDate = now(); + + Cache::forever('laravel_xr_EUR_GBP_'.$mockDate->format('Y-m-d'), 0.123456); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class); + $requestBuilderMock->expects('makeRequest')->never(); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $rate = $exchangeRate->exchangeRate('EUR', 'GBP', $mockDate); + $this->assertEquals('0.123456', $rate); + $this->assertEquals('0.123456', Cache::get('laravel_xr_EUR_GBP_'.$mockDate->format('Y-m-d'))); + } + + /** @test */ + public function multiple_exchange_rates_can_be_returned_if_no_date_parameter_passed_and_rate_is_not_cached(): void + { + $requestBuilderMock = Mockery::mock(RequestBuilder::class); + $requestBuilderMock->expects('makeRequest') + ->withArgs(['/latest', ['base' => 'EUR', 'symbols' => 'GBP,USD,CAD']]) + ->once() + ->andReturn($this->mockResponseForCurrentDateAndMultipleSymbols()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $response = $exchangeRate->exchangeRate('EUR', ['GBP', 'USD', 'CAD']); + $this->assertEquals(['CAD' => 1.4561, 'USD' => 1.1034, 'GBP' => 0.86158], $response); + $this->assertEquals( + ['CAD' => 1.4561, 'USD' => 1.1034, 'GBP' => 0.86158], + Cache::get('laravel_xr_EUR_CAD_GBP_USD_'.now()->format('Y-m-d')) + ); + } + + /** @test */ + public function multiple_exchange_rates_can_be_returned_if_date_parameter_passed_and_rate_is_not_cached(): void + { + $mockDate = now(); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class); + $requestBuilderMock->expects('makeRequest') + ->withArgs([ + '/historical', + [ + 'base' => 'EUR', + 'symbols' => 'GBP,CAD,USD', + 'date' => $mockDate->format('Y-m-d'), + ], + ]) + ->once() + ->andReturn($this->mockResponseForPastDateAndMultipleSymbols()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $response = $exchangeRate->exchangeRate('EUR', ['GBP', 'CAD', 'USD'], $mockDate); + $this->assertEquals(['CAD' => 1.4969, 'USD' => 1.1346, 'GBP' => 0.87053], $response); + $this->assertEquals( + ['CAD' => 1.4969, 'USD' => 1.1346, 'GBP' => 0.87053], + Cache::get('laravel_xr_EUR_CAD_GBP_USD_'.$mockDate->format('Y-m-d')) + ); + } + + /** @test */ + public function multiple_cached_exchange_rates_are_returned_if_they_exist(): void + { + $mockDate = now(); + + Cache::forget('laravel_xr_EUR_CAD_GBP_USD_'.$mockDate->format('Y-m-d')); + + Cache::forever('laravel_xr_EUR_CAD_GBP_USD_'.$mockDate->format('Y-m-d'), + ['CAD' => 1.4561, 'USD' => 1.1034, 'GBP' => 0.86158] + ); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class); + $requestBuilderMock->expects('makeRequest')->never(); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $rate = $exchangeRate->exchangeRate('EUR', ['GBP', 'USD', 'CAD'], $mockDate); + $this->assertEquals(['CAD' => 1.4561, 'USD' => 1.1034, 'GBP' => 0.86158], $rate); + $this->assertEquals( + ['CAD' => 1.4561, 'USD' => 1.1034, 'GBP' => 0.86158], + Cache::get('laravel_xr_EUR_CAD_GBP_USD_'.$mockDate->format('Y-m-d')) + ); + } + + /** @test */ + public function cached_exchange_rate_is_not_used_if_should_bust_cache_method_is_called(): void + { + $mockDate = now(); + + Cache::forever('laravel_xr_EUR_GBP_'.$mockDate->format('Y-m-d'), '0.123456'); + + $requestBuilderMock = Mockery::mock(RequestBuilder::class); + $requestBuilderMock->expects('makeRequest') + ->withArgs([ + '/historical', + [ + 'base' => 'EUR', + 'symbols' => 'GBP', + 'date' => $mockDate->format('Y-m-d'), + ], + ]) + ->once() + ->andReturn($this->mockResponseForPastDateAndOneSymbol()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $rate = $exchangeRate->shouldBustCache()->exchangeRate('EUR', 'GBP', $mockDate); + $this->assertEquals('0.87053', $rate); + $this->assertEquals('0.87053', Cache::get('laravel_xr_EUR_GBP_'.$mockDate->format('Y-m-d'))); + } + + /** @test */ + public function exchange_rate_is_not_cached_if_the_shouldCache_option_is_false(): void + { + $requestBuilderMock = Mockery::mock(RequestBuilder::class); + $requestBuilderMock->expects('makeRequest') + ->withArgs(['/latest', ['base' => 'EUR', 'symbols' => 'GBP']]) + ->once() + ->andReturn($this->mockResponseForCurrentDateAndOneSymbol()); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $rate = $exchangeRate->shouldCache(false)->exchangeRate('EUR', 'GBP'); + $this->assertEquals('0.86158', $rate); + $this->assertNull(Cache::get('laravel_xr_EUR_GBP_'.now()->format('Y-m-d'))); + } + + /** @test */ + public function request_is_not_made_if_the_symbols_are_the_same(): void + { + $requestBuilderMock = Mockery::mock(RequestBuilder::class); + $requestBuilderMock->expects('makeRequest')->withAnyArgs()->never(); + + $exchangeRate = new CurrencyBeaconDriver($requestBuilderMock); + $rate = $exchangeRate->exchangeRate('EUR', 'EUR'); + $this->assertEquals(1.0, $rate); + } + + /** @test */ + public function exception_is_thrown_if_the_date_parameter_passed_is_in_the_future(): void + { + $this->expectException(InvalidDateException::class); + $this->expectExceptionMessage('The date must be in the past.'); + + $exchangeRate = new CurrencyBeaconDriver(); + $exchangeRate->exchangeRate('EUR', 'GBP', now()->addMinute()); + } + + /** @test */ + public function exception_is_thrown_if_the_from_parameter_is_invalid(): void + { + $this->expectException(InvalidCurrencyException::class); + $this->expectExceptionMessage('INVALID is not a valid currency code.'); + + $exchangeRate = new CurrencyBeaconDriver(); + $exchangeRate->exchangeRate('INVALID', 'GBP', now()->subMinute()); + } + + /** @test */ + public function exception_is_thrown_if_the_to_parameter_is_invalid(): void + { + $this->expectException(InvalidCurrencyException::class); + $this->expectExceptionMessage('INVALID is not a valid currency code.'); + + $exchangeRate = new CurrencyBeaconDriver(); + $exchangeRate->exchangeRate('GBP', 'INVALID', now()->subMinute()); + } + + /** @test */ + public function exception_is_thrown_if_the_to_parameter_array_is_invalid(): void + { + $this->expectException(InvalidCurrencyException::class); + $this->expectExceptionMessage('INVALID is not a valid currency code.'); + + $exchangeRate = new CurrencyBeaconDriver(); + $exchangeRate->exchangeRate('GBP', ['INVALID'], now()->subMinute()); + } + + private function mockResponseForCurrentDateAndOneSymbol(): Response + { + return new Response([ + 'meta' => [ + 'code' => 200, + 'disclaimer' => 'Usage subject to terms: https:\/\/currencybeacon.com\/terms', + ], + 'response' => [ + 'date' => '2024-02-07T10:00:07Z', + 'base' => 'EUR', + 'rates' => [ + 'GBP' => 0.86158, + ], + ], + ]); + } + + private function mockResponseForCurrentDateAndMultipleSymbols(): Response + { + return new Response([ + 'meta' => [ + 'code' => 200, + 'disclaimer' => 'Usage subject to terms: https:\/\/currencybeacon.com\/terms', + ], + 'response' => [ + 'date' => '2024-02-07T10:00:07Z', + 'base' => 'EUR', + 'rates' => [ + 'CAD' => 1.4561, + 'USD' => 1.1034, + 'GBP' => 0.86158, + ], + ], + ]); + } + + private function mockResponseForPastDateAndOneSymbol(): Response + { + return new Response([ + 'meta' => [ + 'code' => 200, + 'disclaimer' => 'Usage subject to terms: https:\/\/currencybeacon.com\/terms', + ], + 'response' => [ + 'date' => '2023-10-27T10:00:07Z', + 'base' => 'EUR', + 'rates' => [ + 'GBP' => 0.87053, + ], + ], + ]); + } + + private function mockResponseForPastDateAndMultipleSymbols(): Response + { + return new Response([ + 'meta' => [ + 'code' => 200, + 'disclaimer' => 'Usage subject to terms: https:\/\/currencybeacon.com\/terms', + ], + 'response' => [ + 'date' => '2023-10-27T10:00:07Z', + 'base' => 'EUR', + 'rates' => [ + 'CAD' => 1.4969, + 'USD' => 1.1346, + 'GBP' => 0.87053, + ], + ], + ]); + } +} diff --git a/tests/Unit/Drivers/CurrencyBeacon/RequestBuilderTest.php b/tests/Unit/Drivers/CurrencyBeacon/RequestBuilderTest.php new file mode 100644 index 0000000..d7bf224 --- /dev/null +++ b/tests/Unit/Drivers/CurrencyBeacon/RequestBuilderTest.php @@ -0,0 +1,77 @@ + 'API-KEY']); + } + + /** @test */ + public function request_can_be_made_successfully(): void + { + $url = 'https://api.currencybeacon.com/v1/latest?api_key=API-KEY&base=USD'; + + Http::fake([ + $url => Http::response(['RESPONSE']), + '*' => Http::response('SHOULD NOT HIT THIS!', 500), + ]); + + $requestBuilder = new RequestBuilder(); + $requestBuilder->makeRequest('latest', ['base' => 'USD']); + + Http::assertSent(static function (Request $request) use ($url): bool { + return $request->method() === 'GET' + && $request->url() === $url; + }); + } + + /** @test */ + public function request_protocol_respects_https_config_option(): void + { + config(['laravel-exchange-rates.https' => false]); + + $noHttpsUrl = 'http://api.currencybeacon.com/v1/latest?api_key=API-KEY&base=USD'; + + Http::fake([ + $noHttpsUrl => Http::response(['RESPONSE']), + '*' => Http::response('SHOULD NOT HIT THIS!', 500), + ]); + + $requestBuilder = new RequestBuilder(); + $requestBuilder->makeRequest('latest', ['base' => 'USD']); + + Http::assertSent(static function (Request $request) use ($noHttpsUrl): bool { + return $request->method() === 'GET' + && $request->url() === $noHttpsUrl; + }); + } + + /** @test */ + public function exception_is_thrown_if_the_request_fails(): void + { + $this->expectException(RequestException::class); + + $url = 'https://api.currencybeacon.com/v1/latest?api_key=API-KEY&base=USD'; + + Http::fake([ + $url => Http::response(['RESPONSE'], 401), + '*' => Http::response('SHOULD NOT HIT THIS!', 500), + ]); + + $requestBuilder = new RequestBuilder(); + $requestBuilder->makeRequest('latest', ['base' => 'USD']); + } +} diff --git a/tests/Unit/Drivers/ExchangeRateHost/RequestBuilderTest.php b/tests/Unit/Drivers/ExchangeRateHost/RequestBuilderTest.php index 3ed87aa..2057f1f 100644 --- a/tests/Unit/Drivers/ExchangeRateHost/RequestBuilderTest.php +++ b/tests/Unit/Drivers/ExchangeRateHost/RequestBuilderTest.php @@ -64,7 +64,7 @@ public function exception_is_thrown_if_the_request_fails(): void { $this->expectException(RequestException::class); - $url = 'https://api.exchangeratesapi.io/v1/latest?access_key=API-KEY&base=USD'; + $url = 'https://api.exchangerate.host/latest?access_key=API-KEY&base=USD'; Http::fake([ $url => Http::response(['RESPONSE'], 401),