diff --git a/src/Commands/Refresh.php b/src/Commands/Refresh.php index 01167e2..dada513 100644 --- a/src/Commands/Refresh.php +++ b/src/Commands/Refresh.php @@ -8,6 +8,7 @@ use Illuminate\Console\Command; use Illuminate\Contracts\Cache\Repository as CacheRepository; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Str; final class Refresh extends Command { @@ -117,17 +118,34 @@ protected function resolveBases(): array $args = (array) $this->argument('bases'); if ($args !== []) { - return array_values(array_map('strtoupper', $args)); + return $this->normalizeBases($args); } $configured = config('rates.bases'); if (is_array($configured) && $configured !== []) { - return array_values(array_filter($configured, 'is_string')); + return $this->normalizeBases($configured); } $default = config('rates.base'); - return is_string($default) ? [$default] : []; + return is_string($default) ? $this->normalizeBases([$default]) : []; + } + + /** + * Normalize base strings for cache-key consistency. + * + * @param array $bases + * @return array + */ + protected function normalizeBases(array $bases): array + { + return array_values(array_filter( + array_map( + fn (mixed $base): ?string => is_string($base) ? (string) Str::of($base)->trim()->upper() : null, + $bases, + ), + fn (?string $base): bool => $base !== null && $base !== '', + )); } } diff --git a/src/Commands/Status.php b/src/Commands/Status.php index d516eb8..04146f2 100644 --- a/src/Commands/Status.php +++ b/src/Commands/Status.php @@ -8,6 +8,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Str; final class Status extends Command { @@ -84,11 +85,28 @@ protected function resolveBases(): array $args = (array) $this->argument('bases'); if ($args !== []) { - return array_values(array_map('strtoupper', $args)); + return $this->normalizeBases($args); } $configured = config('rates.bases'); - return is_array($configured) ? array_values(array_filter($configured, 'is_string')) : []; + return is_array($configured) ? $this->normalizeBases($configured) : []; + } + + /** + * Normalize base strings for cache-key consistency. + * + * @param array $bases + * @return array + */ + protected function normalizeBases(array $bases): array + { + return array_values(array_filter( + array_map( + fn (mixed $base): ?string => is_string($base) ? (string) Str::of($base)->trim()->upper() : null, + $bases, + ), + fn (?string $base): bool => $base !== null && $base !== '', + )); } } diff --git a/src/Concerns/InteractsWithCache.php b/src/Concerns/InteractsWithCache.php index 115a146..1a448cc 100644 --- a/src/Concerns/InteractsWithCache.php +++ b/src/Concerns/InteractsWithCache.php @@ -49,11 +49,27 @@ public function flush(Currency|string|null $base = null): void $store = $this->cacheStore(); if ($base !== null) { - $store->forget($this->cacheKey($this->resolveBase($base))); + $key = $this->cacheKey($this->resolveBase($base)); + + $store->forget($key); + $this->forgetCacheKey($store, $key); return; } + $keys = $store->get($this->cacheIndexKey(), []); + + if (is_array($keys)) { + foreach ($keys as $key) { + if (is_string($key)) { + $store->forget($key); + } + } + } + + $store->forget($this->cacheIndexKey()); + + // Clear legacy entries created before cache keys were indexed. foreach (Currency::cases() as $currency) { $store->forget($this->cacheKey($currency->value)); } @@ -69,6 +85,8 @@ protected function cacheRate(CacheRepository $store, string $key, Rate $rate): v $this->applyFreshUntil($rate); $store->put($key, $rate, $this->staleTtl()); + + $this->rememberCacheKey($store, $key); } /** @@ -171,6 +189,58 @@ protected function cacheKey(string $base): string return "{$prefix}:{$base}"; } + /** + * Track cache keys so flush() can clear custom string bases too. + */ + protected function rememberCacheKey(CacheRepository $store, string $key): void + { + $indexKey = $this->cacheIndexKey(); + $keys = $store->get($indexKey, []); + + if (! is_array($keys)) { + $keys = []; + } + + if (! in_array($key, $keys, true)) { + $keys[] = $key; + } + + $store->forever($indexKey, array_values($keys)); + } + + /** + * Remove a key from the tracked cache-key index. + */ + protected function forgetCacheKey(CacheRepository $store, string $key): void + { + $indexKey = $this->cacheIndexKey(); + $keys = $store->get($indexKey, []); + + if (! is_array($keys)) { + return; + } + + $keys = array_values(array_filter($keys, fn (mixed $cachedKey): bool => $cachedKey !== $key)); + + if ($keys === []) { + $store->forget($indexKey); + + return; + } + + $store->forever($indexKey, $keys); + } + + /** + * Build the key used to index all cached rate keys. + */ + protected function cacheIndexKey(): string + { + $prefix = config('rates.cache.prefix', 'rates'); + + return "{$prefix}:__keys"; + } + /** * Get the stale TTL in seconds (total time before eviction from cache). */ diff --git a/src/Concerns/ResolvesDrivers.php b/src/Concerns/ResolvesDrivers.php index 787f0f1..7e3a615 100644 --- a/src/Concerns/ResolvesDrivers.php +++ b/src/Concerns/ResolvesDrivers.php @@ -20,6 +20,7 @@ use AtoBeach\Rates\Rate; use Closure; use Illuminate\Support\Str; +use InvalidArgumentException; /** * Driver-resolution machinery, modelled on Illuminate\Support\Manager. @@ -113,11 +114,19 @@ protected function resolveBase(Currency|string|null $base): string return $base->value; } - if ($base !== null) { - return $base; + $base ??= config('rates.base', 'USD'); + + if (! is_string($base)) { + throw new InvalidArgumentException('Base currency must be a string or Currency enum.'); + } + + $base = (string) Str::of($base)->trim()->upper(); + + if (! preg_match('/^[A-Z]{3}$/', $base)) { + throw new InvalidArgumentException("Invalid base currency [{$base}]. Expected a three-letter ISO 4217 code."); } - return config('rates.base', 'USD'); + return $base; } /** diff --git a/src/Conversion.php b/src/Conversion.php index 18417eb..51b3511 100644 --- a/src/Conversion.php +++ b/src/Conversion.php @@ -30,7 +30,7 @@ public function __construct( private Currency|string|null $from = null, private ?Rate $rate = null, ) { - $this->amount = (string) $amount; + $this->amount = self::normalizeNumber($amount); } /** @@ -41,6 +41,8 @@ public function __construct( */ public static function bcround(string $value, int $precision): string { + $value = self::normalizeNumber($value); + if ($precision < 0) { $precision = 0; } @@ -132,6 +134,42 @@ public function toMany(array $currencies): array return $result; } + /** + * Normalize input to the plain decimal format expected by bcmath. + */ + private static function normalizeNumber(float|int|string $value): string + { + if (is_float($value)) { + if (! is_finite($value)) { + throw ConversionException::invalidAmount((string) $value); + } + + $value = (string) Str::of(sprintf('%.14F', $value))->rtrim('0')->rtrim('.'); + } + + $value = (string) Str::of((string) $value)->trim(); + + if ($value === '') { + throw ConversionException::invalidAmount($value); + } + + if (str_starts_with($value, '+')) { + $value = mb_substr($value, 1); + } + + if (str_starts_with($value, '.')) { + $value = '0'.$value; + } elseif (str_starts_with($value, '-.')) { + $value = '-0'.mb_substr($value, 1); + } + + if (! preg_match('/^-?(?:\d+|\d+\.\d+)$/', $value)) { + throw ConversionException::invalidAmount($value); + } + + return $value === '-0' ? '0' : $value; + } + /** * Resolve the Rate instance needed for conversion. * diff --git a/src/Drivers/ApilayerCurrencyData.php b/src/Drivers/ApilayerCurrencyData.php index 9b9bae4..d22470d 100644 --- a/src/Drivers/ApilayerCurrencyData.php +++ b/src/Drivers/ApilayerCurrencyData.php @@ -18,7 +18,9 @@ class ApilayerCurrencyData extends HttpDriver */ public function url(string $base): string { - return "https://api.apilayer.com/currency_data/live?source={$base}"; + return 'https://api.apilayer.com/currency_data/live?'.http_build_query([ + 'source' => $base, + ], '', '&', PHP_QUERY_RFC3986); } /** @@ -45,6 +47,10 @@ protected function hydrate(Rate $rate, Fluent $data): Rate return $rate; } + if (! $this->matchesRequestedBase($rate, $data->source, 'source')) { + return $rate; + } + $rate->source = 'Apilayer Currency Data'; $rate->timestamp = $data->timestamp; diff --git a/src/Drivers/CurrencyLayer.php b/src/Drivers/CurrencyLayer.php index a14d6f3..926dc28 100644 --- a/src/Drivers/CurrencyLayer.php +++ b/src/Drivers/CurrencyLayer.php @@ -19,7 +19,10 @@ public function url(string $base): string { $key = config('rates.currencylayer.key'); - return "https://api.currencylayer.com/live?access_key={$key}&source={$base}"; + return 'https://api.currencylayer.com/live?'.http_build_query([ + 'access_key' => (string) $key, + 'source' => $base, + ], '', '&', PHP_QUERY_RFC3986); } /** @@ -36,6 +39,10 @@ protected function hydrate(Rate $rate, Fluent $data): Rate return $rate; } + if (! $this->matchesRequestedBase($rate, $data->source, 'source')) { + return $rate; + } + $rate->source = 'CurrencyLayer'; $rate->timestamp = $data->timestamp; diff --git a/src/Drivers/Driver.php b/src/Drivers/Driver.php index 23df359..d67bced 100644 --- a/src/Drivers/Driver.php +++ b/src/Drivers/Driver.php @@ -6,7 +6,9 @@ use AtoBeach\Rates\Contracts\Driver as DriverContract; use AtoBeach\Rates\Rate; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Fluent; +use Illuminate\Support\Str; abstract class Driver implements DriverContract { @@ -116,4 +118,30 @@ protected function unpackPairQuotes(array $quotes, string $source): array return $rates; } + + /** + * Ensure the upstream response is quoted against the requested base. + */ + protected function matchesRequestedBase(Rate $rate, mixed $actual, string $field = 'base'): bool + { + if (! is_string($actual)) { + return true; + } + + $actual = (string) Str::of($actual)->trim()->upper(); + + if ($actual === '') { + return true; + } + + if ($actual === $rate->base) { + return true; + } + + $provider = static::PROVIDER ?? static::class; + + Log::warning("[{$provider}] Response {$field} ({$actual}) differs from requested base ({$rate->base}); ignoring response."); + + return false; + } } diff --git a/src/Drivers/EuropeanCentralBank.php b/src/Drivers/EuropeanCentralBank.php index fef1de5..bef7cd5 100644 --- a/src/Drivers/EuropeanCentralBank.php +++ b/src/Drivers/EuropeanCentralBank.php @@ -66,14 +66,28 @@ public function update(Command $command): void (string) Str::of($this->getDatabasePath())->basename() ); - $response = Http::withOptions(['sink' => $path])->get( - $this->getDatabaseUrl() - ); + $temporaryPath = tempnam($root, 'rates-ecb-'); throw_if( - $response->failed(), - new RuntimeException('Failed to download European Central Bank reference file. Response: '.$response->body()) + $temporaryPath === false, + new RuntimeException('Failed to create a temporary European Central Bank reference file.') + ); + + $response = Http::withOptions(['sink' => $temporaryPath])->get( + $this->getDatabaseUrl() ); + + if ($response->failed()) { + @unlink($temporaryPath); + + throw new RuntimeException('Failed to download European Central Bank reference file. Response: '.$response->body()); + } + + if (! @rename($temporaryPath, $path)) { + @unlink($temporaryPath); + + throw new RuntimeException('Failed to replace European Central Bank reference file.'); + } } /** diff --git a/src/Drivers/ExchangeRateApi.php b/src/Drivers/ExchangeRateApi.php index 603a541..1aa9009 100644 --- a/src/Drivers/ExchangeRateApi.php +++ b/src/Drivers/ExchangeRateApi.php @@ -17,10 +17,10 @@ class ExchangeRateApi extends HttpDriver public function url(string $base): string { if ($key = config('rates.exchange_rate_api.key')) { - return "https://v6.exchangerate-api.com/v6/{$key}/latest/{$base}"; + return 'https://v6.exchangerate-api.com/v6/'.rawurlencode((string) $key).'/latest/'.rawurlencode($base); } - return "https://open.er-api.com/v6/latest/{$base}"; + return 'https://open.er-api.com/v6/latest/'.rawurlencode($base); } /** @@ -28,6 +28,10 @@ public function url(string $base): string */ protected function hydrate(Rate $rate, Fluent $data): Rate { + if (! $this->matchesRequestedBase($rate, $data->base_code ?? $data->base)) { + return $rate; + } + $rate->source = 'ExchangeRate-API'; $rate->rates = (array) ($data->conversion_rates ?? $data->rates ?? []); $rate->timestamp = $data->time_last_update_unix; diff --git a/src/Drivers/ExchangeRateHost.php b/src/Drivers/ExchangeRateHost.php index 59d6169..67f9f94 100644 --- a/src/Drivers/ExchangeRateHost.php +++ b/src/Drivers/ExchangeRateHost.php @@ -19,7 +19,10 @@ public function url(string $base): string { $key = config('rates.exchangerate_host.key'); - return "https://api.exchangerate.host/live?access_key={$key}&source={$base}"; + return 'https://api.exchangerate.host/live?'.http_build_query([ + 'access_key' => (string) $key, + 'source' => $base, + ], '', '&', PHP_QUERY_RFC3986); } /** @@ -36,6 +39,10 @@ protected function hydrate(Rate $rate, Fluent $data): Rate return $rate; } + if (! $this->matchesRequestedBase($rate, $data->source, 'source')) { + return $rate; + } + $rate->source = 'exchangerate.host'; $rate->timestamp = $data->timestamp; diff --git a/src/Drivers/FawazAhmed.php b/src/Drivers/FawazAhmed.php index c66ab34..228541e 100644 --- a/src/Drivers/FawazAhmed.php +++ b/src/Drivers/FawazAhmed.php @@ -17,7 +17,7 @@ class FawazAhmed extends HttpDriver */ public function url(string $base): string { - $code = Str::lower($base); + $code = rawurlencode(Str::lower($base)); return "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/{$code}.json"; } diff --git a/src/Drivers/Fixer.php b/src/Drivers/Fixer.php index aee51fc..531f72f 100644 --- a/src/Drivers/Fixer.php +++ b/src/Drivers/Fixer.php @@ -19,7 +19,10 @@ public function url(string $base): string { $key = config('rates.fixer.key'); - return "https://data.fixer.io/api/latest?access_key={$key}&base={$base}"; + return 'https://data.fixer.io/api/latest?'.http_build_query([ + 'access_key' => (string) $key, + 'base' => $base, + ], '', '&', PHP_QUERY_RFC3986); } /** @@ -36,6 +39,10 @@ protected function hydrate(Rate $rate, Fluent $data): Rate return $rate; } + if (! $this->matchesRequestedBase($rate, $data->base)) { + return $rate; + } + $rate->source = 'Fixer'; $rate->date = $data->date; $rate->timestamp = $data->timestamp; diff --git a/src/Drivers/Frankfurter.php b/src/Drivers/Frankfurter.php index c40919c..d72e8e3 100644 --- a/src/Drivers/Frankfurter.php +++ b/src/Drivers/Frankfurter.php @@ -16,7 +16,9 @@ class Frankfurter extends HttpDriver */ public function url(string $base): string { - return "https://api.frankfurter.dev/v1/latest?from={$base}"; + return 'https://api.frankfurter.dev/v1/latest?'.http_build_query([ + 'from' => $base, + ], '', '&', PHP_QUERY_RFC3986); } /** @@ -24,6 +26,10 @@ public function url(string $base): string */ protected function hydrate(Rate $rate, Fluent $data): Rate { + if (! $this->matchesRequestedBase($rate, $data->base)) { + return $rate; + } + $rate->source = 'European Central Bank'; $rate->date = $data->date; $rate->rates = (array) ($data->rates ?? []); diff --git a/src/Drivers/OpenExchangeRates.php b/src/Drivers/OpenExchangeRates.php index b3b9c84..6101aa0 100644 --- a/src/Drivers/OpenExchangeRates.php +++ b/src/Drivers/OpenExchangeRates.php @@ -5,7 +5,6 @@ namespace AtoBeach\Rates\Drivers; use AtoBeach\Rates\Rate; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Fluent; class OpenExchangeRates extends HttpDriver @@ -19,7 +18,10 @@ public function url(string $base): string { $appId = config('rates.openexchangerates.app_id'); - return "https://openexchangerates.org/api/latest.json?app_id={$appId}&base={$base}"; + return 'https://openexchangerates.org/api/latest.json?'.http_build_query([ + 'app_id' => (string) $appId, + 'base' => $base, + ], '', '&', PHP_QUERY_RFC3986); } /** @@ -27,6 +29,10 @@ public function url(string $base): string */ protected function hydrate(Rate $rate, Fluent $data): Rate { + if (! $this->matchesRequestedBase($rate, $data->base)) { + return $rate; + } + $rate->source = 'Open Exchange Rates'; $rate->timestamp = $data->timestamp; $rate->rates = (array) ($data->rates ?? []); @@ -35,15 +41,6 @@ protected function hydrate(Rate $rate, Fluent $data): Rate $rate->date = gmdate('Y-m-d', $data->timestamp); } - // Free-tier Open Exchange Rates keys always return USD regardless of - // the requested base. Align the Rate with the response's actual base - // and log so the caller knows their requested base was ignored. - if ($data->base && $data->base !== $rate->base) { - Log::warning("[openexchangerates] Response base ({$data->base}) differs from requested base ({$rate->base}); upgrade plan to change base."); - - $rate->base = $data->base; - } - return $rate; } } diff --git a/src/Exceptions/ConversionException.php b/src/Exceptions/ConversionException.php index 617eed6..0dd6739 100644 --- a/src/Exceptions/ConversionException.php +++ b/src/Exceptions/ConversionException.php @@ -20,4 +20,9 @@ public static function fetchFailed(string $base): static { return new static("Failed to fetch exchange rates for base [{$base}]."); } + + public static function invalidAmount(string $amount): static + { + return new static("Invalid conversion amount [{$amount}]."); + } } diff --git a/src/Rate.php b/src/Rate.php index ab5da02..fa4ba7c 100644 --- a/src/Rate.php +++ b/src/Rate.php @@ -5,7 +5,6 @@ namespace AtoBeach\Rates; use Illuminate\Contracts\Support\Arrayable; -use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; @@ -93,9 +92,7 @@ public static function make(array $attributes = []): static */ public function isEmpty(): bool { - $data = Arr::except($this->toArray(), ['base', 'driver']); - - return empty(array_filter($data)); + return $this->rates === []; } /** diff --git a/src/RatesManager.php b/src/RatesManager.php index 451f882..c7160df 100644 --- a/src/RatesManager.php +++ b/src/RatesManager.php @@ -10,6 +10,7 @@ use AtoBeach\Rates\Exceptions\DriverDoesNotExistException; use AtoBeach\Rates\Exceptions\RatesUnavailableException; use Illuminate\Support\Traits\Macroable; +use InvalidArgumentException; class RatesManager { @@ -47,6 +48,12 @@ public function setDriver(Driver $driver): static */ public function setFallbacks(array $fallbacks): static { + foreach ($fallbacks as $fallback) { + if (! $fallback instanceof Driver) { + throw new InvalidArgumentException('Fallback drivers must implement '.Driver::class.'.'); + } + } + $this->fallbacksOverride = array_values($fallbacks); return $this; diff --git a/tests/ApilayerCurrencyDataTest.php b/tests/ApilayerCurrencyDataTest.php index c2b4ef1..dece4fa 100644 --- a/tests/ApilayerCurrencyDataTest.php +++ b/tests/ApilayerCurrencyDataTest.php @@ -93,3 +93,26 @@ Http::assertSent(fn ($request) => $request->hasHeader('apikey', 'test-key-123')); }); + +it('falls back when the response source differs from the requested base', function (): void { + Http::fake([ + 'api.apilayer.com/*' => Http::response([ + 'success' => true, + 'timestamp' => 1_704_153_601, + 'source' => 'USD', + 'quotes' => ['USDEUR' => 0.9123], + ]), + ]); + + $fallback = m::mock(Driver::class); + $fallback->shouldReceive('get')->once()->with('EUR')->andReturn(Rate::make([ + 'base' => 'EUR', + 'driver' => 'fallback', + 'rates' => ['USD' => 1.0961], + ])); + + Rates::setDriver(new ApilayerCurrencyData); + Rates::setFallbacks([$fallback]); + + expect(Rates::get('EUR')->driver)->toBe('fallback'); +}); diff --git a/tests/CacheTest.php b/tests/CacheTest.php index c7ab55a..d14c2bb 100644 --- a/tests/CacheTest.php +++ b/tests/CacheTest.php @@ -292,6 +292,27 @@ Rates::get('EUR'); // cache miss → resolve (was flushed) }); +it('flushes custom string bases outside the currency enum', function (): void { + config([ + 'rates.cache.enabled' => true, + 'rates.cache.store' => 'array', + ]); + + $driver = fakeRateDriver(); + $driver->shouldReceive('get')->twice()->with('XBT')->andReturnUsing(fn (string $base): Rate => Rate::make([ + 'base' => $base, + 'driver' => $driver::class, + 'rates' => ['USD' => 50000.0], + ])); + + Rates::setDriver($driver); + Rates::setFallbacks([]); + + Rates::get('xbt'); + Rates::flush(); + Rates::get('XBT'); +}); + it('flush is a no-op when caching is disabled', function (): void { config(['rates.cache.enabled' => false]); diff --git a/tests/ConversionTest.php b/tests/ConversionTest.php index 3973c13..65d1e0b 100644 --- a/tests/ConversionTest.php +++ b/tests/ConversionTest.php @@ -116,6 +116,26 @@ $conversion->to(Currency::GBP); })->throws(ConversionException::class, 'No source currency specified.'); +it('throws a conversion exception for invalid amount strings', function (): void { + $rate = Rate::make([ + 'base' => 'USD', + 'driver' => 'test', + 'rates' => ['EUR' => 0.92], + ]); + + $rate->convert('1e3', to: Currency::EUR); +})->throws(ConversionException::class, 'Invalid conversion amount [1e3].'); + +it('normalizes decimal amount strings for bcmath', function (): void { + $rate = Rate::make([ + 'base' => 'USD', + 'driver' => 'test', + 'rates' => ['EUR' => 0.92], + ]); + + expect($rate->convert('.5', to: Currency::EUR))->toBe('0.46'); +}); + it('uses bcmath for precise multiplication', function (): void { // This test verifies that we don't lose precision due to // floating-point multiplication. 0.1 + 0.2 != 0.3 in IEEE 754, diff --git a/tests/CurrencyLayerTest.php b/tests/CurrencyLayerTest.php index 5b1bed3..ab46a8e 100644 --- a/tests/CurrencyLayerTest.php +++ b/tests/CurrencyLayerTest.php @@ -5,9 +5,11 @@ namespace AtoBeach\Rates\Tests; use AtoBeach\Rates\Drivers\CurrencyLayer; +use AtoBeach\Rates\Drivers\Driver; use AtoBeach\Rates\Facades\Rates; use AtoBeach\Rates\Rate; use Illuminate\Support\Facades\Http; +use Mockery as m; it('it can process fluent response', function (): void { Http::fake([ @@ -44,3 +46,26 @@ ], ]); }); + +it('falls back when the response source differs from the requested base', function (): void { + Http::fake([ + 'api.currencylayer.com/*' => Http::response([ + 'success' => true, + 'timestamp' => 1_704_153_601, + 'source' => 'USD', + 'quotes' => ['USDEUR' => 0.9123], + ]), + ]); + + $fallback = m::mock(Driver::class); + $fallback->shouldReceive('get')->once()->with('EUR')->andReturn(Rate::make([ + 'base' => 'EUR', + 'driver' => 'fallback', + 'rates' => ['USD' => 1.0961], + ])); + + Rates::setDriver(new CurrencyLayer); + Rates::setFallbacks([$fallback]); + + expect(Rates::get('EUR')->driver)->toBe('fallback'); +}); diff --git a/tests/EuropeanCentralBankTest.php b/tests/EuropeanCentralBankTest.php index 2a2b06a..d49864e 100644 --- a/tests/EuropeanCentralBankTest.php +++ b/tests/EuropeanCentralBankTest.php @@ -8,7 +8,10 @@ use AtoBeach\Rates\Drivers\EuropeanCentralBank; use AtoBeach\Rates\Facades\Rates; use AtoBeach\Rates\Rate; +use Illuminate\Console\Command; use Illuminate\Support\Facades\Http; +use Mockery as m; +use RuntimeException; it('can update reference file', function (): void { config([ @@ -24,6 +27,30 @@ expect(database_path('ecb/eurofxref-daily.xml'))->toBeFile(); }); +it('preserves the existing reference file when update fails', function (): void { + $path = tempnam(sys_get_temp_dir(), 'rates-ecb-test-'); + + file_put_contents($path, 'original'); + + config([ + 'rates.ecb.local.path' => $path, + 'rates.ecb.local.url' => 'http://example.com/fail', + ]); + + Http::fake([ + 'http://example.com/fail' => Http::response('failed', 500), + ]); + + try { + (new EuropeanCentralBank)->update(m::mock(Command::class)); + } catch (RuntimeException $exception) { + expect(file_get_contents($path))->toBe('original'); + @unlink($path); + + throw $exception; + } +})->throws(RuntimeException::class); + it('can read from the reference file for the euro base', function (): void { config(['rates.base' => 'EUR']); config(['rates.driver' => 'ecb']); diff --git a/tests/ExchangeRateApiTest.php b/tests/ExchangeRateApiTest.php index 5061db7..f772608 100644 --- a/tests/ExchangeRateApiTest.php +++ b/tests/ExchangeRateApiTest.php @@ -4,10 +4,12 @@ namespace AtoBeach\Rates\Tests; +use AtoBeach\Rates\Drivers\Driver; use AtoBeach\Rates\Drivers\ExchangeRateApi; use AtoBeach\Rates\Facades\Rates; use AtoBeach\Rates\Rate; use Illuminate\Support\Facades\Http; +use Mockery as m; it('it can process fluent response', function (): void { Http::fake([ @@ -55,3 +57,26 @@ expect((new ExchangeRateApi)->url('USD')) ->toEqual('https://open.er-api.com/v6/latest/USD'); }); + +it('falls back when the response base differs from the requested base', function (): void { + Http::fake([ + 'open.er-api.com/*' => Http::response([ + 'result' => 'success', + 'base_code' => 'USD', + 'time_last_update_unix' => 1_704_153_601, + 'rates' => ['EUR' => 0.9123], + ]), + ]); + + $fallback = m::mock(Driver::class); + $fallback->shouldReceive('get')->once()->with('EUR')->andReturn(Rate::make([ + 'base' => 'EUR', + 'driver' => 'fallback', + 'rates' => ['USD' => 1.0961], + ])); + + Rates::setDriver(new ExchangeRateApi); + Rates::setFallbacks([$fallback]); + + expect(Rates::get('EUR')->driver)->toBe('fallback'); +}); diff --git a/tests/ExchangeRateHostTest.php b/tests/ExchangeRateHostTest.php index 653579c..046a26e 100644 --- a/tests/ExchangeRateHostTest.php +++ b/tests/ExchangeRateHostTest.php @@ -4,10 +4,12 @@ namespace AtoBeach\Rates\Tests; +use AtoBeach\Rates\Drivers\Driver; use AtoBeach\Rates\Drivers\ExchangeRateHost; use AtoBeach\Rates\Facades\Rates; use AtoBeach\Rates\Rate; use Illuminate\Support\Facades\Http; +use Mockery as m; it('it can process fluent response', function (): void { Http::fake([ @@ -42,3 +44,26 @@ ], ]); }); + +it('falls back when the response source differs from the requested base', function (): void { + Http::fake([ + 'api.exchangerate.host/*' => Http::response([ + 'success' => true, + 'timestamp' => 1_704_153_601, + 'source' => 'USD', + 'quotes' => ['USDEUR' => 0.9123], + ]), + ]); + + $fallback = m::mock(Driver::class); + $fallback->shouldReceive('get')->once()->with('EUR')->andReturn(Rate::make([ + 'base' => 'EUR', + 'driver' => 'fallback', + 'rates' => ['USD' => 1.0961], + ])); + + Rates::setDriver(new ExchangeRateHost); + Rates::setFallbacks([$fallback]); + + expect(Rates::get('EUR')->driver)->toBe('fallback'); +}); diff --git a/tests/FixerTest.php b/tests/FixerTest.php index 7d5c380..ce6cb54 100644 --- a/tests/FixerTest.php +++ b/tests/FixerTest.php @@ -29,12 +29,12 @@ Rates::setDriver(new Fixer); Rates::setFallbacks([]); - $rate = Rates::get(); + $rate = Rates::get('EUR'); expect($rate)->toBeInstanceOf(Rate::class); expect($rate->toArray())->toEqual([ - 'base' => 'USD', + 'base' => 'EUR', 'driver' => Fixer::class, 'provider' => 'fixer', 'source' => 'Fixer', @@ -71,3 +71,27 @@ expect(Rates::get())->toBeInstanceOf(Rate::class); }); + +it('falls back when the response base differs from the requested base', function (): void { + Http::fake([ + 'data.fixer.io/*' => Http::response([ + 'success' => true, + 'timestamp' => 1_704_153_601, + 'base' => 'USD', + 'date' => '2024-01-02', + 'rates' => ['EUR' => 0.9123], + ]), + ]); + + $fallback = m::mock(Driver::class); + $fallback->shouldReceive('get')->once()->with('EUR')->andReturn(Rate::make([ + 'base' => 'EUR', + 'driver' => 'fallback', + 'rates' => ['USD' => 1.0961], + ])); + + Rates::setDriver(new Fixer); + Rates::setFallbacks([$fallback]); + + expect(Rates::get('EUR')->driver)->toBe('fallback'); +}); diff --git a/tests/FrankfurterTest.php b/tests/FrankfurterTest.php index e123836..0d57c84 100644 --- a/tests/FrankfurterTest.php +++ b/tests/FrankfurterTest.php @@ -4,10 +4,12 @@ namespace AtoBeach\Rates\Tests; +use AtoBeach\Rates\Drivers\Driver; use AtoBeach\Rates\Drivers\Frankfurter; use AtoBeach\Rates\Facades\Rates; use AtoBeach\Rates\Rate; use Illuminate\Support\Facades\Http; +use Mockery as m; it('it can process fluent response', function (): void { Http::fake([ @@ -42,3 +44,48 @@ ], ]); }); + +it('falls back when the response base differs from the requested base', function (): void { + Http::fake([ + 'api.frankfurter.dev/*' => Http::response([ + 'amount' => 1.0, + 'base' => 'USD', + 'date' => '2024-01-02', + 'rates' => ['EUR' => 0.9123], + ]), + ]); + + $fallback = m::mock(Driver::class); + $fallback->shouldReceive('get')->once()->with('EUR')->andReturn(Rate::make([ + 'base' => 'EUR', + 'driver' => 'fallback', + 'rates' => ['USD' => 1.0961], + ])); + + Rates::setDriver(new Frankfurter); + Rates::setFallbacks([$fallback]); + + expect(Rates::get('EUR')->driver)->toBe('fallback'); +}); + +it('falls back when a successful response has no rates', function (): void { + Http::fake([ + 'api.frankfurter.dev/*' => Http::response([ + 'amount' => 1.0, + 'base' => 'USD', + 'date' => '2024-01-02', + ]), + ]); + + $fallback = m::mock(Driver::class); + $fallback->shouldReceive('get')->once()->with('USD')->andReturn(Rate::make([ + 'base' => 'USD', + 'driver' => 'fallback', + 'rates' => ['EUR' => 0.9123], + ])); + + Rates::setDriver(new Frankfurter); + Rates::setFallbacks([$fallback]); + + expect(Rates::get()->driver)->toBe('fallback'); +}); diff --git a/tests/OpenExchangeRatesTest.php b/tests/OpenExchangeRatesTest.php index 585989b..9024085 100644 --- a/tests/OpenExchangeRatesTest.php +++ b/tests/OpenExchangeRatesTest.php @@ -4,10 +4,13 @@ namespace AtoBeach\Rates\Tests; +use AtoBeach\Rates\Drivers\Driver; use AtoBeach\Rates\Drivers\OpenExchangeRates; use AtoBeach\Rates\Facades\Rates; use AtoBeach\Rates\Rate; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Mockery as m; it('it can process fluent response', function (): void { Http::fake([ @@ -43,3 +46,35 @@ ], ]); }); + +it('falls back and does not cache under the requested base when the response base differs', function (): void { + config([ + 'rates.cache.enabled' => true, + 'rates.cache.store' => 'array', + ]); + + Http::fake([ + 'openexchangerates.org/*' => Http::response([ + 'timestamp' => 1_704_153_601, + 'base' => 'USD', + 'rates' => ['EUR' => 0.9123], + ]), + ]); + + Rates::setDriver(new OpenExchangeRates); + Rates::setFallbacks([]); + + expect(Rates::get('EUR'))->toBeNull(); + expect(Cache::store('array')->get('rates:EUR'))->toBeNull(); + + $fallback = m::mock(Driver::class); + $fallback->shouldReceive('get')->once()->with('EUR')->andReturn(Rate::make([ + 'base' => 'EUR', + 'driver' => 'fallback', + 'rates' => ['USD' => 1.0961], + ])); + + Rates::setFallbacks([$fallback]); + + expect(Rates::get('EUR')->driver)->toBe('fallback'); +}); diff --git a/tests/RateTest.php b/tests/RateTest.php index 9b97cb1..352d997 100644 --- a/tests/RateTest.php +++ b/tests/RateTest.php @@ -52,6 +52,18 @@ expect($rate->isEmpty())->toBeTrue(); }); +it('returns empty when only metadata is present', function (): void { + $rate = Rate::make([ + 'base' => 'USD', + 'driver' => 'test', + 'source' => 'metadata only', + 'date' => '2024-01-02', + 'timestamp' => 1_704_153_601, + ]); + + expect($rate->isEmpty())->toBeTrue(); +}); + it('does not return empty', function (): void { $rate = new Rate; diff --git a/tests/RatesTest.php b/tests/RatesTest.php index 36271e0..8c15c4f 100644 --- a/tests/RatesTest.php +++ b/tests/RatesTest.php @@ -9,6 +9,7 @@ use AtoBeach\Rates\Exceptions\RatesUnavailableException; use AtoBeach\Rates\Facades\Rates; use AtoBeach\Rates\Rate; +use InvalidArgumentException; use Mockery as m; it('falls back to the next driver when the primary returns null', function (): void { @@ -65,3 +66,25 @@ Rates::getOrFail(); })->throws(RatesUnavailableException::class, 'No driver in the chain produced rates for base [GBP].'); + +it('normalizes base strings before resolving drivers', function (): void { + $driver = m::mock(Driver::class); + $driver->shouldReceive('get')->once()->with('USD')->andReturn(Rate::make([ + 'base' => 'USD', + 'driver' => 'test', + 'rates' => ['EUR' => 0.92], + ])); + + Rates::setDriver($driver); + Rates::setFallbacks([]); + + expect(Rates::get(' usd '))->toBeInstanceOf(Rate::class); +}); + +it('rejects invalid base strings', function (): void { + Rates::get('USD&foo=bar'); +})->throws(InvalidArgumentException::class, 'Invalid base currency [USD&FOO=BAR]. Expected a three-letter ISO 4217 code.'); + +it('validates fallback overrides immediately', function (): void { + Rates::setFallbacks(['not-a-driver']); +})->throws(InvalidArgumentException::class);