diff --git a/README.md b/README.md index 2606a8e..e63234b 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # Smartphone Catalog API -A Laravel 12 backend application that serves a smartphone product catalog with real-time currency conversion (USD to UAH) using the National Bank of Ukraine (NBU) exchange rates. +A Laravel 12 backend application that serves a smartphone product catalog with real-time currency conversion (USD/EUR/UAH) using exchange rates from the National Bank of Ukraine (NBU) with automatic fallback to Open Exchange Rates API. ## Features - Browse smartphone catalog sourced from [DummyJSON](https://dummyjson.com/) -- View prices in USD (original) or UAH (converted via NBU rates) -- Retrieve the current USD/UAH exchange rate from the NBU API -- Modular domain-driven architecture for the Currency module +- View prices in **USD** (original), **UAH** or **EUR** (converted via exchange rates) +- Retrieve current exchange rates (USD/UAH, EUR/UAH) +- Modular domain-driven architecture with SOLID principles and design patterns ## Tech Stack @@ -22,9 +22,9 @@ A Laravel 12 backend application that serves a smartphone product catalog with r Returns a list of smartphones with prices in the requested currency. -| Parameter | Type | Allowed Values | Description | -|------------|--------|----------------|--------------------------------------| -| `currency` | string | `usd`, `uah` | Currency for product prices | +| Parameter | Type | Allowed Values | Description | +|------------|--------|-------------------|--------------------------------------| +| `currency` | string | `usd`, `uah`, `eur` | Currency for product prices | **Response (200):** @@ -42,11 +42,19 @@ Returns a list of smartphones with prices in the requested currency. } ``` -Unsupported currencies return **404**. +**Notes:** +- USD returns original prices +- UAH and EUR return converted prices based on current exchange rates +- Unsupported currencies return **404** +- Each request dispatches an asynchronous job to track currency usage + +### `GET /api/exchangeRate/{currency}` -### `GET /api/exchangeRate/USD` +Returns the current exchange rate for the specified currency to UAH. -Returns the current USD to UAH exchange rate from the NBU. +| Parameter | Type | Allowed Values | Description | +|------------|--------|----------------|--------------------------------------| +| `currency` | string | `USD`, `EUR` | Currency code (case-insensitive) | **Response (200):** @@ -60,35 +68,58 @@ Returns the current USD to UAH exchange rate from the NBU. } ``` +**Notes:** +- Exchange rates are cached for 10 minutes +- Cache is automatically invalidated on a new calendar day +- Falls back to secondary provider if primary fails + ## Project Structure ``` app/ ├── Http/ │ ├── Controllers/ -│ │ └── CatalogController.php # Smartphone catalog endpoint +│ │ └── CatalogController.php # Smartphone catalog endpoint │ └── Requests/ -│ └── CatalogRequest.php # Validates currency parameter +│ └── CatalogRequest.php # Validates currency parameter +├── Jobs/ +│ └── TrackCatalogCurrencyUsage.php # Async job for tracking ├── Models/ +│ ├── CatalogCurrencyUsage.php # Currency usage model │ └── User.php └── Modules/ └── Currency/ ├── Application/ │ ├── Facades/ - │ │ └── CurrencyExchangeFacade.php # Entry point for currency operations + │ │ └── CurrencyExchangeFacade.php # Simplified interface │ └── Http/ │ ├── Controllers/ - │ │ └── ExchangeRateController.php + │ │ └── ExchangeRateController.php # Exchange rate endpoint │ └── Requests/ - │ └── ExchangeRateRequest.php + │ └── ExchangeRateRequest.php # Validates currency code ├── Domain/ - │ ├── ConvertedPrice.php # DTO for converted price data - │ ├── CurrencyExchangeService.php # Core exchange logic - │ └── CurrencyRate.php # DTO for rate data + │ ├── Contracts/ + │ │ └── CurrencyRateProviderInterface.php # Provider abstraction + │ ├── ConvertedPrice.php # Value object + │ ├── CurrencyExchangeService.php # Domain service + │ └── CurrencyRate.php # Value object ├── Infrastructure/ - │ └── NbuApiCurrencyRepository.php # NBU API integration + │ ├── ChainProviders/ + │ │ └── ChainOfResponsibilityProvider.php # Fallback mechanism + │ ├── Decorators/ + │ │ └── CachedCurrencyRateProvider.php # Caching decorator + │ └── Providers/ + │ ├── NbuCurrencyRateProvider.php # NBU API integration + │ └── OpenExchangeRateProvider.php # Open ER API integration └── Providers/ - └── CurrencyServiceProvider.php # DI bindings + └── CurrencyServiceProvider.php # DI container bindings + +config/ +└── currency.php # Currency module configuration + +database/ +└── migrations/ + └── 2026_03_12_183729_create_catalog_currency_usage_table.php ``` ## Getting Started @@ -129,6 +160,27 @@ composer dev This starts the Laravel dev server, queue worker, log watcher (Pail), and Vite concurrently. +## Configuration + +The currency module can be configured via `config/currency.php` or environment variables: + +```bash +# Primary exchange rate provider (nbu or open_exchange) +CURRENCY_PROVIDER=nbu + +# Fallback provider (used if primary fails) +CURRENCY_FALLBACK_PROVIDER=open_exchange + +# Enable/disable caching +CURRENCY_CACHE_ENABLED=true + +# Cache TTL in minutes +CURRENCY_CACHE_TTL=10 + +# Cache driver (redis recommended for production) +CACHE_STORE=redis +``` + ## Running Tests ```bash @@ -141,12 +193,28 @@ Or directly with PHPUnit: php artisan test ``` -Tests use `Http::fake()` to mock external API calls (DummyJSON and NBU), so no network access is needed. +Or using Docker: + +```bash +./vendor/bin/sail artisan test +``` + +Tests use `Http::fake()` to mock external API calls (DummyJSON, NBU, Open Exchange Rates), so no network access is needed. ### Test Coverage -- **Catalog endpoint** — USD/UAH responses, price conversion, currency validation, HTTP method restrictions -- **Exchange rate endpoint** — JSON structure, rate values, HTTP method restrictions +**Unit Tests (29):** +- `NbuCurrencyRateProvider` — API integration, error handling +- `OpenExchangeRateProvider` — API integration, rate conversion +- `ChainOfResponsibilityProvider` — Fallback mechanism +- `CachedCurrencyRateProvider` — Caching with TTL and daily invalidation +- `CurrencyExchangeService` — Domain logic for rate retrieval + +**Feature Tests (26):** +- **Catalog endpoint** — USD/UAH/EUR responses, price conversion, currency validation, HTTP method restrictions +- **Exchange rate endpoint** — USD/EUR rates, JSON structure, rate values, HTTP method restrictions +- **Fallback mechanism** — Provider failover, graceful degradation +- **Currency tracking** — Job dispatching, database persistence - Both endpoints verified to not require authentication ## External APIs @@ -154,4 +222,5 @@ Tests use `Http::fake()` to mock external API calls (DummyJSON and NBU), so no n | API | Purpose | URL | |-----|---------|-----| | DummyJSON | Smartphone product data | `https://dummyjson.com/products/category/smartphones` | -| NBU | USD/UAH exchange rate | `https://bank.gov.ua/NBUStatService/v1/statdirectory/exchange` | +| NBU (Primary) | Exchange rates (USD, EUR → UAH) | `https://bank.gov.ua/NBUStatService/v1/statdirectory/exchange` | +| Open Exchange Rates (Fallback) | Exchange rates when NBU fails | `https://open.er-api.com/v6/latest/UAH` | diff --git a/app/Http/Controllers/CatalogController.php b/app/Http/Controllers/CatalogController.php index 57fb8a2..4e345f5 100644 --- a/app/Http/Controllers/CatalogController.php +++ b/app/Http/Controllers/CatalogController.php @@ -3,39 +3,27 @@ namespace App\Http\Controllers; use App\Http\Requests\CatalogRequest; +use App\Jobs\TrackCatalogCurrencyUsage; +use App\Services\Catalog\CatalogServiceInterface; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\Http; -use Modules\Currency\Application\Facades\CurrencyExchangeFacade; class CatalogController extends Controller { public function __construct( - private readonly CurrencyExchangeFacade $currencyExchange, + private readonly CatalogServiceInterface $catalogService, ) {} public function __invoke(CatalogRequest $request): JsonResponse { - $currency = $request->route('currency'); - $response = Http::get('https://dummyjson.com/products/category/smartphones', [ - 'limit' => 5, - ]); + $currency = strtoupper($request->route('currency')); - $products = collect($response->json('products'))->map(function (array $product) use ($currency) { - $price = $product['price']; + TrackCatalogCurrencyUsage::dispatch( + $currency, + $request->ip(), + $request->userAgent() + ); - if (strtoupper($currency) === 'UAH') { - $converted = $this->currencyExchange->convertFromUsdToUah((int) $price); - $price = $converted->convertedPrice; - } - - return [ - 'id' => $product['id'], - 'title' => $product['title'], - 'price' => $price, - 'rating' => $product['rating'], - 'thumbnail' => $product['thumbnail'], - ]; - }); + $products = $this->catalogService->getProducts($currency); return response()->json(['data' => $products]); } diff --git a/app/Http/Requests/CatalogRequest.php b/app/Http/Requests/CatalogRequest.php index f3ee2d8..8398f05 100644 --- a/app/Http/Requests/CatalogRequest.php +++ b/app/Http/Requests/CatalogRequest.php @@ -11,8 +11,13 @@ class CatalogRequest extends FormRequest { public function rules(): array { + $supportedCurrencies = array_map( + 'strtolower', + config('currency.supported_currencies') + ); + return [ - 'currency' => ['required', 'string', Rule::in(['uah', 'usd'])], + 'currency' => ['required', 'string', Rule::in($supportedCurrencies)], ]; } @@ -25,6 +30,6 @@ public function validationData(): array protected function failedValidation(Validator $validator): void { - throw new NotFoundHttpException(); + throw new NotFoundHttpException; } } diff --git a/app/Jobs/TrackCatalogCurrencyUsage.php b/app/Jobs/TrackCatalogCurrencyUsage.php new file mode 100644 index 0000000..eaa150c --- /dev/null +++ b/app/Jobs/TrackCatalogCurrencyUsage.php @@ -0,0 +1,47 @@ + strtoupper($this->currency), + 'used_at' => now(), + 'ip_address' => $this->ipAddress, + 'user_agent' => $this->userAgent, + ]); + + Log::info('Catalog currency usage tracked', [ + 'currency' => $this->currency, + 'ip' => $this->ipAddress, + ]); + } catch (\Exception $e) { + Log::error('Failed to track catalog currency usage', [ + 'currency' => $this->currency, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Models/CatalogCurrencyUsage.php b/app/Models/CatalogCurrencyUsage.php new file mode 100644 index 0000000..69e1557 --- /dev/null +++ b/app/Models/CatalogCurrencyUsage.php @@ -0,0 +1,21 @@ + 'datetime', + ]; +} diff --git a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php index 2e62b92..fa6aa9b 100644 --- a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php +++ b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php @@ -3,35 +3,40 @@ namespace Modules\Currency\Application\Facades; use Modules\Currency\Domain\ConvertedPrice; -use Modules\Currency\Domain\CurrencyExchangeService; +use Modules\Currency\Domain\Contracts\CurrencyConverterInterface; +use Modules\Currency\Domain\Contracts\CurrencyExchangeServiceInterface; use Modules\Currency\Domain\CurrencyRate; -class CurrencyExchangeFacade +class CurrencyExchangeFacade implements CurrencyExchangeFacadeInterface { - private const string USD_CODE = 'USD'; - private const string UAH_CODE = 'UAH'; - public function __construct( - private readonly CurrencyExchangeService $service, + private readonly CurrencyExchangeServiceInterface $exchangeService, + private readonly CurrencyConverterInterface $currencyConverter, ) {} - public function getUsdRate(): CurrencyRate + /** + * Get exchange rate for specific currency code. + * + * @param string $currencyCode Currency code (USD, EUR, etc.) + * @return CurrencyRate Currency rate information + * @throws \RuntimeException If currency rate not found + */ + public function getRateByCurrencyCode(string $currencyCode): CurrencyRate { - return $this->service->findUsdRate(); + return $this->exchangeService->findRateByCurrencyCode($currencyCode); } - public function convertFromUsdToUah(int $price): ConvertedPrice + /** + * Convert price from one currency to another. + * + * @param int $priceInCents Original price in cents + * @param string $fromCurrency Source currency code + * @param string $toCurrency Target currency code + * @return ConvertedPrice Conversion result with details + * @throws \RuntimeException If currency rate not found + */ + public function convert(int $priceInCents, string $fromCurrency, string $toCurrency): ConvertedPrice { - $usdRate = $this->service->findUsdRate(); - - $convertedPrice = $price * $usdRate->rate; - - return new ConvertedPrice( - originalPrice: $price, - convertedPrice: round($convertedPrice, 2), - fromCurrency: self::USD_CODE, - toCurrency: self::UAH_CODE, - rate: $usdRate->rate, - ); + return $this->currencyConverter->convert($priceInCents, $fromCurrency, $toCurrency); } } diff --git a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacadeInterface.php b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacadeInterface.php new file mode 100644 index 0000000..8dcf473 --- /dev/null +++ b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacadeInterface.php @@ -0,0 +1,29 @@ +currencyExchange->getUsdRate(); + $currencyCode = strtoupper($request->route('currency')); + $rate = $this->currencyExchange->getRateByCurrencyCode($currencyCode); return response()->json([ 'data' => [ diff --git a/app/Modules/Currency/Application/Http/Requests/ExchangeRateRequest.php b/app/Modules/Currency/Application/Http/Requests/ExchangeRateRequest.php index b9fbeaa..3ccc092 100644 --- a/app/Modules/Currency/Application/Http/Requests/ExchangeRateRequest.php +++ b/app/Modules/Currency/Application/Http/Requests/ExchangeRateRequest.php @@ -3,11 +3,25 @@ namespace Modules\Currency\Application\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class ExchangeRateRequest extends FormRequest { public function rules(): array { - return []; + return [ + 'currency' => [ + 'required', + 'string', + Rule::in(config('currency.supported_currencies')), + ], + ]; + } + + public function validationData(): array + { + return array_merge(parent::validationData(), [ + 'currency' => strtoupper($this->route('currency')), + ]); } } diff --git a/app/Modules/Currency/Domain/Contracts/CurrencyConverterInterface.php b/app/Modules/Currency/Domain/Contracts/CurrencyConverterInterface.php new file mode 100644 index 0000000..b71f3ee --- /dev/null +++ b/app/Modules/Currency/Domain/Contracts/CurrencyConverterInterface.php @@ -0,0 +1,19 @@ +convertSameCurrency($priceInCents, $fromCurrency); + } + + return $this->convertBetweenCurrencies($priceInCents, $fromCurrency, $toCurrency); + } + + private function convertSameCurrency(int $priceInCents, string $currency): ConvertedPrice + { + return new ConvertedPrice( + originalPrice: $priceInCents / 100, + convertedPrice: $priceInCents / 100, + fromCurrency: $currency, + toCurrency: $currency, + rate: 1.0, + ); + } + + private function convertBetweenCurrencies(int $priceInCents, string $fromCurrency, string $toCurrency): ConvertedPrice + { + $priceInDollars = $priceInCents / 100; + $fromRate = $this->exchangeService->findRateByCurrencyCode($fromCurrency); + + if ($toCurrency === self::UAH_CODE) { + return $this->convertToUah($priceInDollars, $fromCurrency, $fromRate); + } + + return $this->convertThroughUah($priceInDollars, $fromCurrency, $toCurrency, $fromRate); + } + + private function convertToUah(float $priceInDollars, string $fromCurrency, CurrencyRate $fromRate): ConvertedPrice + { + $convertedPrice = $priceInDollars * $fromRate->rate; + + return new ConvertedPrice( + originalPrice: $priceInDollars, + convertedPrice: round($convertedPrice, 2), + fromCurrency: $fromCurrency, + toCurrency: self::UAH_CODE, + rate: $fromRate->rate, + ); + } + + private function convertThroughUah(float $priceInDollars, string $fromCurrency, string $toCurrency, CurrencyRate $fromRate): ConvertedPrice + { + $toRate = $this->exchangeService->findRateByCurrencyCode($toCurrency); + $uahPrice = $priceInDollars * $fromRate->rate; + $convertedPrice = $uahPrice / $toRate->rate; + + return new ConvertedPrice( + originalPrice: $priceInDollars, + convertedPrice: round($convertedPrice, 2), + fromCurrency: $fromCurrency, + toCurrency: $toCurrency, + rate: $convertedPrice / $priceInDollars, + ); + } +} diff --git a/app/Modules/Currency/Domain/CurrencyExchangeService.php b/app/Modules/Currency/Domain/CurrencyExchangeService.php index 877121e..8aac5fd 100644 --- a/app/Modules/Currency/Domain/CurrencyExchangeService.php +++ b/app/Modules/Currency/Domain/CurrencyExchangeService.php @@ -2,32 +2,68 @@ namespace Modules\Currency\Domain; -use Modules\Currency\Infrastructure\NbuApiCurrencyRepository; +use Modules\Currency\Domain\Contracts\CurrencyExchangeServiceInterface; +use Modules\Currency\Domain\Contracts\CurrencyRateProviderInterface; -class CurrencyExchangeService +class CurrencyExchangeService implements CurrencyExchangeServiceInterface { private const string USD_CODE = 'USD'; + private const string EUR_CODE = 'EUR'; + public function __construct( - private readonly NbuApiCurrencyRepository $repository, + private readonly CurrencyRateProviderInterface $provider, ) {} /** + * Get all available currency rates. + * * @return CurrencyRate[] */ public function getRates(): array { - return $this->repository->getAll(); + return $this->provider->getAll(); } + /** + * Find USD exchange rate. + * + * @return CurrencyRate USD rate information + * @throws \RuntimeException If USD rate not found + */ public function findUsdRate(): CurrencyRate { - foreach ($this->repository->getAll() as $currency) { - if ($currency->currencyCode === self::USD_CODE) { - return $currency; - } + return $this->findRateByCurrencyCode(self::USD_CODE); + } + + /** + * Find EUR exchange rate. + * + * @return CurrencyRate EUR rate information + * @throws \RuntimeException If EUR rate not found + */ + public function findEurRate(): CurrencyRate + { + return $this->findRateByCurrencyCode(self::EUR_CODE); + } + + /** + * Find exchange rate by currency code. + * + * @param string $currencyCode Currency code (USD, EUR, etc.) + * @return CurrencyRate Currency rate information + * @throws \RuntimeException If currency rate not found + */ + public function findRateByCurrencyCode(string $currencyCode): CurrencyRate + { + $rate = $this->provider->findByCurrencyCode($currencyCode); + + if ($rate === null) { + throw new \RuntimeException( + sprintf('%s rate not found in exchange rates', strtoupper($currencyCode)) + ); } - throw new \RuntimeException('USD rate not found in exchange rates'); + return $rate; } } diff --git a/app/Modules/Currency/Infrastructure/ChainProviders/ChainOfResponsibilityProvider.php b/app/Modules/Currency/Infrastructure/ChainProviders/ChainOfResponsibilityProvider.php new file mode 100644 index 0000000..29c6f43 --- /dev/null +++ b/app/Modules/Currency/Infrastructure/ChainProviders/ChainOfResponsibilityProvider.php @@ -0,0 +1,102 @@ +providers = $providers; + } + + /** + * @return CurrencyRate[] + */ + public function getAll(): array + { + foreach ($this->providers as $provider) { + try { + $rates = $provider->getAll(); + + if (! empty($rates)) { + Log::info('Currency rates fetched successfully', [ + 'provider' => $provider->getName(), + 'rates_count' => count($rates), + ]); + + return $rates; + } + + Log::warning('Provider returned empty rates, trying next provider', [ + 'provider' => $provider->getName(), + ]); + } catch (\Exception $e) { + Log::error('Provider failed, trying next provider', [ + 'provider' => $provider->getName(), + 'error' => $e->getMessage(), + ]); + } + } + + Log::critical('All currency rate providers failed'); + + return []; + } + + public function findByCurrencyCode(string $currencyCode): ?CurrencyRate + { + foreach ($this->providers as $provider) { + try { + $rate = $provider->findByCurrencyCode($currencyCode); + + if ($rate !== null) { + Log::info('Currency rate found', [ + 'provider' => $provider->getName(), + 'currency' => $currencyCode, + ]); + + return $rate; + } + + Log::debug('Currency not found in provider, trying next', [ + 'provider' => $provider->getName(), + 'currency' => $currencyCode, + ]); + } catch (\Exception $e) { + Log::error('Provider failed when searching for currency', [ + 'provider' => $provider->getName(), + 'currency' => $currencyCode, + 'error' => $e->getMessage(), + ]); + } + } + + Log::warning('Currency rate not found in any provider', [ + 'currency' => $currencyCode, + ]); + + return null; + } + + public function getName(): string + { + $providerNames = array_map( + fn (CurrencyRateProviderInterface $provider) => $provider->getName(), + $this->providers + ); + + return 'Chain: '.implode(' → ', $providerNames); + } +} diff --git a/app/Modules/Currency/Infrastructure/Decorators/CachedCurrencyRateProvider.php b/app/Modules/Currency/Infrastructure/Decorators/CachedCurrencyRateProvider.php new file mode 100644 index 0000000..721339f --- /dev/null +++ b/app/Modules/Currency/Infrastructure/Decorators/CachedCurrencyRateProvider.php @@ -0,0 +1,119 @@ +provider = $provider; + $this->cacheEnabled = $cacheEnabled ?? config('currency.cache.enabled', true); + $this->cacheTtl = $cacheTtl ?? config('currency.cache.ttl', 10); + $this->cachePrefix = $cachePrefix ?? config('currency.cache.prefix', 'currency_rate'); + } + + /** + * @return CurrencyRate[] + */ + public function getAll(): array + { + if (! $this->cacheEnabled) { + return $this->provider->getAll(); + } + + $cacheKey = $this->getCacheKey('all'); + + return Cache::remember( + $cacheKey, + $this->getCacheTtlInSeconds(), + function () use ($cacheKey) { + Log::debug('Cache miss, fetching from provider', [ + 'cache_key' => $cacheKey, + 'provider' => $this->provider->getName(), + ]); + + return $this->provider->getAll(); + } + ); + } + + public function findByCurrencyCode(string $currencyCode): ?CurrencyRate + { + if (! $this->cacheEnabled) { + return $this->provider->findByCurrencyCode($currencyCode); + } + + $cacheKey = $this->getCacheKey($currencyCode); + + return Cache::remember( + $cacheKey, + $this->getCacheTtlInSeconds(), + function () use ($currencyCode, $cacheKey) { + Log::debug('Cache miss for currency, fetching from provider', [ + 'cache_key' => $cacheKey, + 'currency' => $currencyCode, + 'provider' => $this->provider->getName(), + ]); + + return $this->provider->findByCurrencyCode($currencyCode); + } + ); + } + + public function getName(): string + { + return 'Cached('.$this->provider->getName().')'; + } + + /** + * Get cache key with current date to invalidate cache on new calendar day. + */ + private function getCacheKey(string $suffix): string + { + $currentDate = now()->format('Y-m-d'); + + return "{$this->cachePrefix}:{$currentDate}:{$suffix}"; + } + + /** + * Get cache TTL in seconds. + */ + private function getCacheTtlInSeconds(): int + { + return $this->cacheTtl * 60; + } + + /** + * Clear all cached rates. + */ + public function clearCache(): void + { + $currentDate = now()->format('Y-m-d'); + $pattern = "{$this->cachePrefix}:{$currentDate}:*"; + + Log::info('Clearing currency rate cache', ['pattern' => $pattern]); + + Cache::forget($this->getCacheKey('all')); + + foreach (config('currency.supported_currencies', []) as $currency) { + Cache::forget($this->getCacheKey($currency)); + } + } +} diff --git a/app/Modules/Currency/Infrastructure/Providers/NbuCurrencyRateProvider.php b/app/Modules/Currency/Infrastructure/Providers/NbuCurrencyRateProvider.php new file mode 100644 index 0000000..f9ae684 --- /dev/null +++ b/app/Modules/Currency/Infrastructure/Providers/NbuCurrencyRateProvider.php @@ -0,0 +1,76 @@ +apiUrl = $apiUrl ?? config('currency.providers.nbu.url'); + $this->timeout = $timeout ?? config('currency.providers.nbu.timeout', 10); + } + + /** + * @return CurrencyRate[] + */ + public function getAll(): array + { + try { + $response = Http::timeout($this->timeout) + ->get($this->apiUrl, ['json' => '']); + + if (! $response->successful()) { + Log::warning('NBU API request failed', [ + 'status' => $response->status(), + 'provider' => $this->getName(), + ]); + + return []; + } + + return array_map( + fn (array $item) => new CurrencyRate( + name: $item['txt'], + rate: (float) $item['rate'], + currencyCode: $item['cc'], + exchangeDate: $item['exchangedate'], + ), + $response->json(), + ); + } catch (\Exception $e) { + Log::error('NBU API exception', [ + 'message' => $e->getMessage(), + 'provider' => $this->getName(), + ]); + + return []; + } + } + + public function findByCurrencyCode(string $currencyCode): ?CurrencyRate + { + $rates = $this->getAll(); + + foreach ($rates as $rate) { + if ($rate->currencyCode === strtoupper($currencyCode)) { + return $rate; + } + } + + return null; + } + + public function getName(): string + { + return 'NBU (National Bank of Ukraine)'; + } +} diff --git a/app/Modules/Currency/Infrastructure/Providers/OpenExchangeRateProvider.php b/app/Modules/Currency/Infrastructure/Providers/OpenExchangeRateProvider.php new file mode 100644 index 0000000..25a07b6 --- /dev/null +++ b/app/Modules/Currency/Infrastructure/Providers/OpenExchangeRateProvider.php @@ -0,0 +1,130 @@ +apiUrl = $apiUrl ?? config('currency.providers.open_exchange.url'); + $this->timeout = $timeout ?? config('currency.providers.open_exchange.timeout', self::TIMEOUT); + } + + /** + * @return CurrencyRate[] + */ + public function getAll(): array + { + try { + $response = Http::timeout($this->timeout)->get($this->apiUrl); + + if (! $response->successful()) { + Log::warning('Open Exchange Rates API request failed', [ + 'status' => $response->status(), + 'provider' => $this->getName(), + ]); + + return []; + } + + $data = $response->json(); + + if (! isset($data['rates']) || ! is_array($data['rates'])) { + Log::warning('Open Exchange Rates API returned invalid data', [ + 'provider' => $this->getName(), + ]); + + return []; + } + + $exchangeDate = $this->formatExchangeDate($data['time_last_update_unix'] ?? time()); + $rates = []; + + foreach ($data['rates'] as $currencyCode => $rate) { + if ($currencyCode === self::UAH_CODE) { + continue; + } + + $uahRate = $data['rates'][self::UAH_CODE] ?? 1; + $convertedRate = $uahRate / $rate; + + $rates[] = new CurrencyRate( + name: $this->getCurrencyName($currencyCode), + rate: round($convertedRate, 4), + currencyCode: $currencyCode, + exchangeDate: $exchangeDate, + ); + } + + return $rates; + } catch (\Exception $e) { + Log::error('Open Exchange Rates API exception', [ + 'message' => $e->getMessage(), + 'provider' => $this->getName(), + ]); + + return []; + } + } + + public function findByCurrencyCode(string $currencyCode): ?CurrencyRate + { + $rates = $this->getAll(); + + foreach ($rates as $rate) { + if ($rate->currencyCode === strtoupper($currencyCode)) { + return $rate; + } + } + + return null; + } + + public function getName(): string + { + return 'Open Exchange Rates'; + } + + private function formatExchangeDate(int $timestamp): string + { + return date('d.m.Y', $timestamp); + } + + private function getCurrencyName(string $currencyCode): string + { + $names = [ + self::USD_CODE => 'Долар США', + self::EUR_CODE => 'Євро', + self::GBP_CODE => 'Фунт стерлінгів', + self::JPY_CODE => 'Єна', + self::CNY_CODE => 'Юань', + ]; + + return $names[$currencyCode] ?? $currencyCode; + } +} diff --git a/app/Modules/Currency/Providers/CurrencyServiceProvider.php b/app/Modules/Currency/Providers/CurrencyServiceProvider.php index cd7ad9f..857179e 100644 --- a/app/Modules/Currency/Providers/CurrencyServiceProvider.php +++ b/app/Modules/Currency/Providers/CurrencyServiceProvider.php @@ -3,24 +3,84 @@ namespace Modules\Currency\Providers; use Illuminate\Support\ServiceProvider; -use Modules\Currency\Domain\CurrencyExchangeService; use Modules\Currency\Application\Facades\CurrencyExchangeFacade; -use Modules\Currency\Infrastructure\NbuApiCurrencyRepository; +use Modules\Currency\Application\Facades\CurrencyExchangeFacadeInterface; +use Modules\Currency\Domain\Contracts\CurrencyConverterInterface; +use Modules\Currency\Domain\Contracts\CurrencyExchangeServiceInterface; +use Modules\Currency\Domain\Contracts\CurrencyRateProviderInterface; +use Modules\Currency\Domain\CurrencyConverter; +use Modules\Currency\Domain\CurrencyExchangeService; +use Modules\Currency\Infrastructure\ChainProviders\ChainOfResponsibilityProvider; +use Modules\Currency\Infrastructure\Decorators\CachedCurrencyRateProvider; class CurrencyServiceProvider extends ServiceProvider { public function register(): void { - $this->app->singleton(CurrencyExchangeService::class, function ($app) { - return new CurrencyExchangeService( - $app->make(NbuApiCurrencyRepository::class), - ); - }); + $this->app->singleton(CurrencyRateProviderInterface::class, function ($app) { + $providers = $this->buildProviderChain(); + + $chainProvider = new ChainOfResponsibilityProvider($providers); - $this->app->singleton(CurrencyExchangeFacade::class, function ($app) { - return new CurrencyExchangeFacade( - $app->make(CurrencyExchangeService::class), - ); + if (config('currency.cache.enabled', true)) { + return new CachedCurrencyRateProvider($chainProvider); + } + + return $chainProvider; }); + + $this->app->singleton(CurrencyExchangeServiceInterface::class, CurrencyExchangeService::class); + + $this->app->singleton(CurrencyConverterInterface::class, CurrencyConverter::class); + + $this->app->singleton(CurrencyExchangeFacadeInterface::class, CurrencyExchangeFacade::class); + } + + public function boot(): void + { + $this->publishes([ + __DIR__.'/../../../../config/currency.php' => config_path('currency.php'), + ], 'config'); + } + + /** + * Build the provider chain based on configuration. + * + * @return CurrencyRateProviderInterface[] + */ + private function buildProviderChain(): array + { + $providers = []; + + $defaultProvider = config('currency.default', 'nbu'); + $fallbackProvider = config('currency.fallback'); + + $providers[] = $this->createProvider($defaultProvider); + + if ($fallbackProvider && $fallbackProvider !== $defaultProvider) { + $providers[] = $this->createProvider($fallbackProvider); + } + + return $providers; + } + + private function createProvider(string $providerName): CurrencyRateProviderInterface + { + $providerConfig = config("currency.providers.{$providerName}"); + + if (! $providerConfig || ! isset($providerConfig['class'])) { + throw new \RuntimeException("Currency provider '{$providerName}' is not configured"); + } + + $class = $providerConfig['class']; + + if (! class_exists($class)) { + throw new \RuntimeException("Currency provider class '{$class}' does not exist"); + } + + return new $class( + $providerConfig['url'] ?? null, + $providerConfig['timeout'] ?? null + ); } } diff --git a/app/Providers/CurrencyConversionServiceProvider.php b/app/Providers/CurrencyConversionServiceProvider.php new file mode 100644 index 0000000..6261a84 --- /dev/null +++ b/app/Providers/CurrencyConversionServiceProvider.php @@ -0,0 +1,28 @@ +app->singleton(CurrencyConverterInterface::class, function ($app) { + return new CurrencyConverter([ + $app->make(UsdConversionStrategy::class), + $app->make(UahConversionStrategy::class), + $app->make(EurConversionStrategy::class), + ]); + }); + + $this->app->singleton(CatalogServiceInterface::class, CatalogService::class); + } +} diff --git a/app/Services/Catalog/CatalogService.php b/app/Services/Catalog/CatalogService.php new file mode 100644 index 0000000..0d271c3 --- /dev/null +++ b/app/Services/Catalog/CatalogService.php @@ -0,0 +1,48 @@ + + */ + public function getProducts(string $currency): Collection + { + $response = Http::get(self::PRODUCTS_API_URL, [ + 'limit' => self::PRODUCTS_LIMIT, + ]); + + return collect($response->json('products'))->map( + fn (array $product) => $this->transformProduct($product, $currency) + ); + } + + private function transformProduct(array $product, string $currency): array + { + $priceInCents = (int) round($product['price'] * 100); + + return [ + 'id' => $product['id'], + 'title' => $product['title'], + 'price' => $this->currencyConverter->convert($priceInCents, $currency), + 'rating' => $product['rating'], + 'thumbnail' => $product['thumbnail'], + ]; + } +} diff --git a/app/Services/Catalog/CatalogServiceInterface.php b/app/Services/Catalog/CatalogServiceInterface.php new file mode 100644 index 0000000..f40e8c7 --- /dev/null +++ b/app/Services/Catalog/CatalogServiceInterface.php @@ -0,0 +1,16 @@ + + */ + public function getProducts(string $currency): Collection; +} diff --git a/app/Services/CurrencyConversion/CurrencyConversionStrategyInterface.php b/app/Services/CurrencyConversion/CurrencyConversionStrategyInterface.php new file mode 100644 index 0000000..31d673b --- /dev/null +++ b/app/Services/CurrencyConversion/CurrencyConversionStrategyInterface.php @@ -0,0 +1,10 @@ + $strategies + */ + public function __construct( + private readonly array $strategies + ) {} + + /** + * Convert price from one currency to another. + * + * @param int $priceInCents Original price in cents + * @param string $currency Target currency code + * @return int Converted price in cents + * @throws RuntimeException If no conversion strategy found for currency + */ + public function convert(int $priceInCents, string $currency): int + { + foreach ($this->strategies as $strategy) { + if ($strategy->supports($currency)) { + return $strategy->convert($priceInCents); + } + } + + throw new RuntimeException("No conversion strategy found for currency: {$currency}"); + } +} diff --git a/app/Services/CurrencyConversion/CurrencyConverterInterface.php b/app/Services/CurrencyConversion/CurrencyConverterInterface.php new file mode 100644 index 0000000..15c421a --- /dev/null +++ b/app/Services/CurrencyConversion/CurrencyConverterInterface.php @@ -0,0 +1,16 @@ +currencyExchange->convert($priceInCents, self::FROM_CURRENCY, self::TO_CURRENCY); + + return (int) round($converted->convertedPrice * 100); + } + + /** + * Check if this strategy supports the given currency. + * + * @param string $currency Currency code + * @return bool True if currency is EUR + */ + public function supports(string $currency): bool + { + return strtoupper($currency) === self::TO_CURRENCY; + } +} diff --git a/app/Services/CurrencyConversion/UahConversionStrategy.php b/app/Services/CurrencyConversion/UahConversionStrategy.php new file mode 100644 index 0000000..f482e93 --- /dev/null +++ b/app/Services/CurrencyConversion/UahConversionStrategy.php @@ -0,0 +1,40 @@ +currencyExchange->convert($priceInCents, self::FROM_CURRENCY, self::TO_CURRENCY); + + return (int) round($converted->convertedPrice * 100); + } + + /** + * Check if this strategy supports the given currency. + * + * @param string $currency Currency code + * @return bool True if currency is UAH + */ + public function supports(string $currency): bool + { + return strtoupper($currency) === self::TO_CURRENCY; + } +} diff --git a/app/Services/CurrencyConversion/UsdConversionStrategy.php b/app/Services/CurrencyConversion/UsdConversionStrategy.php new file mode 100644 index 0000000..c61730c --- /dev/null +++ b/app/Services/CurrencyConversion/UsdConversionStrategy.php @@ -0,0 +1,30 @@ + env('CURRENCY_PROVIDER', 'nbu'), + + /* + |-------------------------------------------------------------------------- + | Fallback Currency Rate Provider + |-------------------------------------------------------------------------- + | + | This option controls the fallback currency rate provider that will be used + | when the default provider fails or times out. Set to null to disable + | fallback mechanism. + | + */ + + 'fallback' => env('CURRENCY_FALLBACK_PROVIDER', 'open_exchange'), + + /* + |-------------------------------------------------------------------------- + | Currency Rate Providers + |-------------------------------------------------------------------------- + | + | Here you may configure the currency rate providers for your application. + | Each provider can have its own configuration options. + | + */ + + 'providers' => [ + 'nbu' => [ + 'class' => \Modules\Currency\Infrastructure\Providers\NbuCurrencyRateProvider::class, + 'url' => 'https://bank.gov.ua/NBUStatService/v1/statdirectory/exchange', + 'timeout' => 10, + ], + + 'open_exchange' => [ + 'class' => \Modules\Currency\Infrastructure\Providers\OpenExchangeRateProvider::class, + 'url' => 'https://open.er-api.com/v6/latest/UAH', + 'timeout' => 10, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Cache Configuration + |-------------------------------------------------------------------------- + | + | Configure caching behavior for exchange rates. + | TTL is in minutes. Cache will be invalidated on a new calendar day. + | + */ + + 'cache' => [ + 'enabled' => env('CURRENCY_CACHE_ENABLED', true), + 'ttl' => env('CURRENCY_CACHE_TTL', 10), // minutes + 'prefix' => 'currency_rate', + ], + + /* + |-------------------------------------------------------------------------- + | Supported Currencies + |-------------------------------------------------------------------------- + | + | List of supported currencies for conversion. + | + */ + + 'supported_currencies' => [ + 'USD', + 'EUR', + 'UAH', + ], +]; diff --git a/database/migrations/2026_03_12_183729_create_catalog_currency_usage_table.php b/database/migrations/2026_03_12_183729_create_catalog_currency_usage_table.php new file mode 100644 index 0000000..6462e9b --- /dev/null +++ b/database/migrations/2026_03_12_183729_create_catalog_currency_usage_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('currency', 3); + $table->timestamp('used_at'); + $table->string('ip_address', 45)->nullable(); + $table->string('user_agent')->nullable(); + $table->timestamps(); + + $table->index('currency'); + $table->index('used_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('catalog_currency_usage'); + } +}; diff --git a/phpunit.xml b/phpunit.xml index bbfcf81..d703241 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,7 +23,8 @@ - + + diff --git a/routes/api.php b/routes/api.php index 3dee5ce..e3a54b7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,4 +5,4 @@ use Modules\Currency\Application\Http\Controllers\ExchangeRateController; Route::get('/catalog/{currency}', CatalogController::class); -Route::get('/exchangeRate/USD', ExchangeRateController::class); +Route::get('/exchangeRate/{currency}', ExchangeRateController::class); diff --git a/tests/Feature/CatalogCurrencyUsageTest.php b/tests/Feature/CatalogCurrencyUsageTest.php new file mode 100644 index 0000000..4c563b2 --- /dev/null +++ b/tests/Feature/CatalogCurrencyUsageTest.php @@ -0,0 +1,106 @@ + Http::response([ + 'products' => [ + [ + 'id' => 1, + 'title' => 'iPhone 5s', + 'description' => 'A classic smartphone.', + 'category' => 'smartphones', + 'price' => 199.99, + 'rating' => 2.83, + 'brand' => 'Apple', + 'thumbnail' => 'https://cdn.dummyjson.com/product-images/smartphones/iphone-5s/thumbnail.webp', + ], + ], + 'total' => 1, + 'skip' => 0, + 'limit' => 5, + ]), + 'bank.gov.ua/*' => Http::response([ + ['r030' => 840, 'txt' => 'Долар США', 'rate' => 43.0996, 'cc' => 'USD', 'exchangedate' => '02.03.2026'], + ['r030' => 978, 'txt' => 'Євро', 'rate' => 50.8661, 'cc' => 'EUR', 'exchangedate' => '02.03.2026'], + ]), + ]); + } + + public function test_catalog_request_dispatches_tracking_job(): void + { + Queue::fake(); + $this->fakeApis(); + + $this->getJson('/api/catalog/USD'); + + Queue::assertPushed(TrackCatalogCurrencyUsage::class); + } + + public function test_tracking_job_saves_currency_usage_to_database(): void + { + $this->fakeApis(); + + $job = new TrackCatalogCurrencyUsage('EUR', '127.0.0.1', 'Test User Agent'); + $job->handle(); + + $this->assertDatabaseHas('catalog_currency_usage', [ + 'currency' => 'EUR', + 'ip_address' => '127.0.0.1', + 'user_agent' => 'Test User Agent', + ]); + } + + public function test_tracking_job_normalizes_currency_to_uppercase(): void + { + $this->fakeApis(); + + $job = new TrackCatalogCurrencyUsage('usd', '127.0.0.1', 'Test User Agent'); + $job->handle(); + + $this->assertDatabaseHas('catalog_currency_usage', [ + 'currency' => 'USD', + ]); + } + + public function test_multiple_catalog_requests_create_multiple_records(): void + { + Queue::fake(); + $this->fakeApis(); + + $this->getJson('/api/catalog/USD'); + $this->getJson('/api/catalog/EUR'); + $this->getJson('/api/catalog/UAH'); + + Queue::assertPushed(TrackCatalogCurrencyUsage::class, 3); + } + + public function test_catalog_currency_usage_model_can_be_queried(): void + { + CatalogCurrencyUsage::create([ + 'currency' => 'USD', + 'used_at' => now(), + 'ip_address' => '127.0.0.1', + 'user_agent' => 'Test User Agent', + ]); + + $usage = CatalogCurrencyUsage::where('currency', 'USD')->first(); + + $this->assertNotNull($usage); + $this->assertEquals('USD', $usage->currency); + $this->assertEquals('127.0.0.1', $usage->ip_address); + } +} diff --git a/tests/Feature/CatalogTest.php b/tests/Feature/CatalogTest.php index 80ba085..ce52ef7 100644 --- a/tests/Feature/CatalogTest.php +++ b/tests/Feature/CatalogTest.php @@ -59,15 +59,9 @@ public function test_catalog_usd_returns_original_usd_price(): void $response = $this->getJson('/api/catalog/usd'); - $response->assertJsonStructure([ - 'data' => [ - '*' => ['id', 'title', 'price', 'rating', 'thumbnail'], - ], - ]); - $data = $response->json('data'); - $this->assertEquals(199.99, $data[0]['price']); - $this->assertEquals(299.99, $data[1]['price']); + $this->assertEquals(19999, $data[0]['price']); + $this->assertEquals(29999, $data[1]['price']); } public function test_catalog_uah_returns_converted_price(): void @@ -77,8 +71,8 @@ public function test_catalog_uah_returns_converted_price(): void $response = $this->getJson('/api/catalog/uah'); $data = $response->json('data'); - $this->assertEquals(199 * 40.0, $data[0]['price']); - $this->assertEquals(299 * 40.0, $data[1]['price']); + $this->assertEquals((int) round(199.99 * 40.0 * 100), $data[0]['price']); + $this->assertEquals((int) round(299.99 * 40.0 * 100), $data[1]['price']); } public function test_catalog_endpoint_does_not_require_authentication(): void @@ -91,11 +85,30 @@ public function test_catalog_endpoint_does_not_require_authentication(): void ->assertJsonStructure(['data']); } - public function test_catalog_eur_returns_not_found(): void + public function test_catalog_eur_returns_successful_response(): void { + $this->fakeApis(); + $response = $this->getJson('/api/catalog/EUR'); - $response->assertStatus(404); + $response->assertStatus(200); + } + + public function test_catalog_eur_returns_converted_price(): void + { + $this->fakeApis(usdRate: 40.0); + + $response = $this->getJson('/api/catalog/EUR'); + + $response->assertJsonStructure([ + 'data' => [ + '*' => ['id', 'title', 'price', 'rating', 'thumbnail'], + ], + ]); + + $data = $response->json('data'); + $this->assertIsInt($data[0]['price']); + $this->assertIsInt($data[1]['price']); } public function test_catalog_rejects_unsupported_currency(): void diff --git a/tests/Feature/CurrencyFallbackTest.php b/tests/Feature/CurrencyFallbackTest.php new file mode 100644 index 0000000..5ccb0aa --- /dev/null +++ b/tests/Feature/CurrencyFallbackTest.php @@ -0,0 +1,105 @@ + Http::response([], 500), + 'open.er-api.com/*' => Http::response([ + 'result' => 'success', + 'time_last_update_unix' => 1709337600, + 'rates' => [ + 'UAH' => 40.0, + 'USD' => 1.0, + 'EUR' => 0.92, + ], + ]), + 'dummyjson.com/*' => Http::response([ + 'products' => [ + [ + 'id' => 1, + 'title' => 'iPhone 5s', + 'description' => 'A classic smartphone.', + 'category' => 'smartphones', + 'price' => 199.99, + 'rating' => 2.83, + 'brand' => 'Apple', + 'thumbnail' => 'https://cdn.dummyjson.com/product-images/smartphones/iphone-5s/thumbnail.webp', + ], + ], + 'total' => 1, + 'skip' => 0, + 'limit' => 5, + ]), + ]); + + $response = $this->getJson('/api/exchangeRate/USD'); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'data' => ['currencyCode', 'rate', 'exchangeDate'], + ]); + + $data = $response->json('data'); + $this->assertEquals('USD', $data['currencyCode']); + $this->assertEquals(40.0, $data['rate']); + } + + public function test_catalog_works_with_fallback_provider(): void + { + Http::fake([ + 'bank.gov.ua/*' => Http::response([], 500), + 'open.er-api.com/*' => Http::response([ + 'result' => 'success', + 'time_last_update_unix' => 1709337600, + 'rates' => [ + 'UAH' => 40.0, + 'USD' => 1.0, + 'EUR' => 0.92, + ], + ]), + 'dummyjson.com/*' => Http::response([ + 'products' => [ + [ + 'id' => 1, + 'title' => 'iPhone 5s', + 'description' => 'A classic smartphone.', + 'category' => 'smartphones', + 'price' => 100, + 'rating' => 2.83, + 'brand' => 'Apple', + 'thumbnail' => 'https://cdn.dummyjson.com/product-images/smartphones/iphone-5s/thumbnail.webp', + ], + ], + 'total' => 1, + 'skip' => 0, + 'limit' => 5, + ]), + ]); + + $response = $this->getJson('/api/catalog/UAH'); + + $response->assertStatus(200); + + $data = $response->json('data'); + $this->assertEquals(400000, $data[0]['price']); // 40.00 UAH * 100 = 4000 kopiykas, but price is 100 USD = 10000 cents * 40 = 400000 kopiykas + } + + public function test_handles_both_providers_failing_gracefully(): void + { + Http::fake([ + 'bank.gov.ua/*' => Http::response([], 500), + 'open.er-api.com/*' => Http::response([], 500), + ]); + + $response = $this->getJson('/api/exchangeRate/USD'); + + $response->assertStatus(500); + } +} diff --git a/tests/Feature/ExchangeRateTest.php b/tests/Feature/ExchangeRateTest.php index 12f238e..42febfe 100644 --- a/tests/Feature/ExchangeRateTest.php +++ b/tests/Feature/ExchangeRateTest.php @@ -68,4 +68,39 @@ public function test_exchange_rate_endpoint_rejects_non_get_methods(): void $this->putJson('/api/exchangeRate/USD')->assertStatus(405); $this->deleteJson('/api/exchangeRate/USD')->assertStatus(405); } + + public function test_exchange_rate_eur_returns_successful_response(): void + { + $this->fakeNbuApi(); + + $response = $this->getJson('/api/exchangeRate/EUR'); + + $response->assertStatus(200); + } + + public function test_exchange_rate_eur_returns_correct_json_structure(): void + { + $this->fakeNbuApi(); + + $response = $this->getJson('/api/exchangeRate/EUR'); + + $response->assertJsonStructure([ + 'data' => ['currencyCode', 'rate', 'exchangeDate'], + ]); + } + + public function test_exchange_rate_eur_returns_correct_rate_value(): void + { + $this->fakeNbuApi(); + + $response = $this->getJson('/api/exchangeRate/EUR'); + + $response->assertJson([ + 'data' => [ + 'currencyCode' => 'EUR', + 'rate' => 50.8661, + 'exchangeDate' => '02.03.2026', + ], + ]); + } } diff --git a/tests/Unit/Currency/CachedCurrencyRateProviderTest.php b/tests/Unit/Currency/CachedCurrencyRateProviderTest.php new file mode 100644 index 0000000..d509a10 --- /dev/null +++ b/tests/Unit/Currency/CachedCurrencyRateProviderTest.php @@ -0,0 +1,117 @@ +createMock(CurrencyRateProviderInterface::class); + $provider->expects($this->once())->method('getAll')->willReturn([$rate]); + $provider->method('getName')->willReturn('Test Provider'); + + $cached = new CachedCurrencyRateProvider($provider, true, 10, 'test_cache'); + + $rates1 = $cached->getAll(); + $rates2 = $cached->getAll(); + + $this->assertCount(1, $rates1); + $this->assertCount(1, $rates2); + $this->assertEquals('USD', $rates1[0]->currencyCode); + } + + public function test_bypasses_cache_when_disabled(): void + { + $rate = new CurrencyRate('Долар США', 43.0, 'USD', '02.03.2026'); + + $provider = $this->createMock(CurrencyRateProviderInterface::class); + $provider->expects($this->exactly(2))->method('getAll')->willReturn([$rate]); + $provider->method('getName')->willReturn('Test Provider'); + + $cached = new CachedCurrencyRateProvider($provider, false, 10, 'test_cache'); + + $cached->getAll(); + $cached->getAll(); + } + + public function test_find_by_currency_code_uses_cache(): void + { + $rate = new CurrencyRate('Долар США', 43.0, 'USD', '02.03.2026'); + + $provider = $this->createMock(CurrencyRateProviderInterface::class); + $provider->expects($this->once())->method('findByCurrencyCode')->with('USD')->willReturn($rate); + $provider->method('getName')->willReturn('Test Provider'); + + $cached = new CachedCurrencyRateProvider($provider, true, 10, 'test_cache'); + + $result1 = $cached->findByCurrencyCode('USD'); + $result2 = $cached->findByCurrencyCode('USD'); + + $this->assertNotNull($result1); + $this->assertNotNull($result2); + $this->assertEquals('USD', $result1->currencyCode); + $this->assertEquals('USD', $result2->currencyCode); + } + + public function test_cache_key_includes_current_date(): void + { + $rate = new CurrencyRate('Долар США', 43.0, 'USD', '02.03.2026'); + + $provider = $this->createMock(CurrencyRateProviderInterface::class); + $provider->method('getAll')->willReturn([$rate]); + $provider->method('getName')->willReturn('Test Provider'); + + $cached = new CachedCurrencyRateProvider($provider, true, 10, 'test_cache'); + $cached->getAll(); + + $currentDate = now()->format('Y-m-d'); + $expectedKey = "test_cache:{$currentDate}:all"; + + $this->assertTrue(Cache::has($expectedKey)); + } + + public function test_clear_cache_removes_cached_rates(): void + { + $rate = new CurrencyRate('Долар США', 43.0, 'USD', '02.03.2026'); + + $provider = $this->createMock(CurrencyRateProviderInterface::class); + $provider->method('getAll')->willReturn([$rate]); + $provider->method('getName')->willReturn('Test Provider'); + + $cached = new CachedCurrencyRateProvider($provider, true, 10, 'test_cache'); + $cached->getAll(); + + $currentDate = now()->format('Y-m-d'); + $cacheKey = "test_cache:{$currentDate}:all"; + + $this->assertTrue(Cache::has($cacheKey)); + + $cached->clearCache(); + + $this->assertFalse(Cache::has($cacheKey)); + } + + public function test_get_name_includes_decorated_provider_name(): void + { + $provider = $this->createMock(CurrencyRateProviderInterface::class); + $provider->method('getName')->willReturn('Test Provider'); + + $cached = new CachedCurrencyRateProvider($provider); + + $this->assertEquals('Cached(Test Provider)', $cached->getName()); + } +} diff --git a/tests/Unit/Currency/ChainOfResponsibilityProviderTest.php b/tests/Unit/Currency/ChainOfResponsibilityProviderTest.php new file mode 100644 index 0000000..c3477e4 --- /dev/null +++ b/tests/Unit/Currency/ChainOfResponsibilityProviderTest.php @@ -0,0 +1,118 @@ +createMock(CurrencyRateProviderInterface::class); + $provider1->method('getAll')->willReturn([$rate1, $rate2]); + $provider1->method('getName')->willReturn('Provider 1'); + + $provider2 = $this->createMock(CurrencyRateProviderInterface::class); + $provider2->expects($this->never())->method('getAll'); + $provider2->method('getName')->willReturn('Provider 2'); + + $chain = new ChainOfResponsibilityProvider([$provider1, $provider2]); + $rates = $chain->getAll(); + + $this->assertCount(2, $rates); + $this->assertEquals('USD', $rates[0]->currencyCode); + $this->assertEquals('EUR', $rates[1]->currencyCode); + } + + public function test_falls_back_to_second_provider_when_first_fails(): void + { + $rate1 = new CurrencyRate('Долар США', 43.0, 'USD', '02.03.2026'); + + $provider1 = $this->createMock(CurrencyRateProviderInterface::class); + $provider1->method('getAll')->willReturn([]); + $provider1->method('getName')->willReturn('Provider 1'); + + $provider2 = $this->createMock(CurrencyRateProviderInterface::class); + $provider2->method('getAll')->willReturn([$rate1]); + $provider2->method('getName')->willReturn('Provider 2'); + + $chain = new ChainOfResponsibilityProvider([$provider1, $provider2]); + $rates = $chain->getAll(); + + $this->assertCount(1, $rates); + $this->assertEquals('USD', $rates[0]->currencyCode); + } + + public function test_returns_empty_array_when_all_providers_fail(): void + { + $provider1 = $this->createMock(CurrencyRateProviderInterface::class); + $provider1->method('getAll')->willReturn([]); + $provider1->method('getName')->willReturn('Provider 1'); + + $provider2 = $this->createMock(CurrencyRateProviderInterface::class); + $provider2->method('getAll')->willReturn([]); + $provider2->method('getName')->willReturn('Provider 2'); + + $chain = new ChainOfResponsibilityProvider([$provider1, $provider2]); + $rates = $chain->getAll(); + + $this->assertEmpty($rates); + } + + public function test_find_by_currency_code_returns_from_first_successful_provider(): void + { + $rate = new CurrencyRate('Долар США', 43.0, 'USD', '02.03.2026'); + + $provider1 = $this->createMock(CurrencyRateProviderInterface::class); + $provider1->method('findByCurrencyCode')->with('USD')->willReturn($rate); + $provider1->method('getName')->willReturn('Provider 1'); + + $provider2 = $this->createMock(CurrencyRateProviderInterface::class); + $provider2->expects($this->never())->method('findByCurrencyCode'); + $provider2->method('getName')->willReturn('Provider 2'); + + $chain = new ChainOfResponsibilityProvider([$provider1, $provider2]); + $result = $chain->findByCurrencyCode('USD'); + + $this->assertNotNull($result); + $this->assertEquals('USD', $result->currencyCode); + } + + public function test_find_by_currency_code_falls_back_to_second_provider(): void + { + $rate = new CurrencyRate('Євро', 50.0, 'EUR', '02.03.2026'); + + $provider1 = $this->createMock(CurrencyRateProviderInterface::class); + $provider1->method('findByCurrencyCode')->with('EUR')->willReturn(null); + $provider1->method('getName')->willReturn('Provider 1'); + + $provider2 = $this->createMock(CurrencyRateProviderInterface::class); + $provider2->method('findByCurrencyCode')->with('EUR')->willReturn($rate); + $provider2->method('getName')->willReturn('Provider 2'); + + $chain = new ChainOfResponsibilityProvider([$provider1, $provider2]); + $result = $chain->findByCurrencyCode('EUR'); + + $this->assertNotNull($result); + $this->assertEquals('EUR', $result->currencyCode); + } + + public function test_get_name_returns_chain_description(): void + { + $provider1 = $this->createMock(CurrencyRateProviderInterface::class); + $provider1->method('getName')->willReturn('Provider 1'); + + $provider2 = $this->createMock(CurrencyRateProviderInterface::class); + $provider2->method('getName')->willReturn('Provider 2'); + + $chain = new ChainOfResponsibilityProvider([$provider1, $provider2]); + + $this->assertEquals('Chain: Provider 1 → Provider 2', $chain->getName()); + } +} diff --git a/tests/Unit/Currency/CurrencyExchangeServiceTest.php b/tests/Unit/Currency/CurrencyExchangeServiceTest.php new file mode 100644 index 0000000..5391ad8 --- /dev/null +++ b/tests/Unit/Currency/CurrencyExchangeServiceTest.php @@ -0,0 +1,95 @@ +createMock(CurrencyRateProviderInterface::class); + $provider->method('getAll')->willReturn([$rate1, $rate2]); + + $service = new CurrencyExchangeService($provider); + $rates = $service->getRates(); + + $this->assertCount(2, $rates); + $this->assertEquals('USD', $rates[0]->currencyCode); + $this->assertEquals('EUR', $rates[1]->currencyCode); + } + + public function test_find_usd_rate_returns_usd_rate(): void + { + $rate = new CurrencyRate('Долар США', 43.0, 'USD', '02.03.2026'); + + $provider = $this->createMock(CurrencyRateProviderInterface::class); + $provider->method('findByCurrencyCode')->with('USD')->willReturn($rate); + + $service = new CurrencyExchangeService($provider); + $result = $service->findUsdRate(); + + $this->assertEquals('USD', $result->currencyCode); + $this->assertEquals(43.0, $result->rate); + } + + public function test_find_eur_rate_returns_eur_rate(): void + { + $rate = new CurrencyRate('Євро', 50.0, 'EUR', '02.03.2026'); + + $provider = $this->createMock(CurrencyRateProviderInterface::class); + $provider->method('findByCurrencyCode')->with('EUR')->willReturn($rate); + + $service = new CurrencyExchangeService($provider); + $result = $service->findEurRate(); + + $this->assertEquals('EUR', $result->currencyCode); + $this->assertEquals(50.0, $result->rate); + } + + public function test_find_rate_by_currency_code_returns_correct_rate(): void + { + $rate = new CurrencyRate('Фунт стерлінгів', 55.0, 'GBP', '02.03.2026'); + + $provider = $this->createMock(CurrencyRateProviderInterface::class); + $provider->method('findByCurrencyCode')->with('GBP')->willReturn($rate); + + $service = new CurrencyExchangeService($provider); + $result = $service->findRateByCurrencyCode('GBP'); + + $this->assertEquals('GBP', $result->currencyCode); + $this->assertEquals(55.0, $result->rate); + } + + public function test_find_rate_by_currency_code_throws_exception_when_not_found(): void + { + $provider = $this->createMock(CurrencyRateProviderInterface::class); + $provider->method('findByCurrencyCode')->with('GBP')->willReturn(null); + + $service = new CurrencyExchangeService($provider); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('GBP rate not found in exchange rates'); + + $service->findRateByCurrencyCode('GBP'); + } + + public function test_find_usd_rate_throws_exception_when_not_found(): void + { + $provider = $this->createMock(CurrencyRateProviderInterface::class); + $provider->method('findByCurrencyCode')->with('USD')->willReturn(null); + + $service = new CurrencyExchangeService($provider); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('USD rate not found in exchange rates'); + + $service->findUsdRate(); + } +} diff --git a/tests/Unit/Currency/NbuCurrencyRateProviderTest.php b/tests/Unit/Currency/NbuCurrencyRateProviderTest.php new file mode 100644 index 0000000..b2e6f6f --- /dev/null +++ b/tests/Unit/Currency/NbuCurrencyRateProviderTest.php @@ -0,0 +1,79 @@ + Http::response([ + ['r030' => 840, 'txt' => 'Долар США', 'rate' => 43.0996, 'cc' => 'USD', 'exchangedate' => '02.03.2026'], + ['r030' => 978, 'txt' => 'Євро', 'rate' => 50.8661, 'cc' => 'EUR', 'exchangedate' => '02.03.2026'], + ]), + ]); + + $provider = new NbuCurrencyRateProvider; + $rates = $provider->getAll(); + + $this->assertCount(2, $rates); + $this->assertEquals('USD', $rates[0]->currencyCode); + $this->assertEquals(43.0996, $rates[0]->rate); + $this->assertEquals('EUR', $rates[1]->currencyCode); + $this->assertEquals(50.8661, $rates[1]->rate); + } + + public function test_find_by_currency_code_returns_correct_rate(): void + { + Http::fake([ + 'bank.gov.ua/*' => Http::response([ + ['r030' => 840, 'txt' => 'Долар США', 'rate' => 43.0996, 'cc' => 'USD', 'exchangedate' => '02.03.2026'], + ['r030' => 978, 'txt' => 'Євро', 'rate' => 50.8661, 'cc' => 'EUR', 'exchangedate' => '02.03.2026'], + ]), + ]); + + $provider = new NbuCurrencyRateProvider; + $rate = $provider->findByCurrencyCode('EUR'); + + $this->assertNotNull($rate); + $this->assertEquals('EUR', $rate->currencyCode); + $this->assertEquals(50.8661, $rate->rate); + } + + public function test_find_by_currency_code_returns_null_when_not_found(): void + { + Http::fake([ + 'bank.gov.ua/*' => Http::response([ + ['r030' => 840, 'txt' => 'Долар США', 'rate' => 43.0996, 'cc' => 'USD', 'exchangedate' => '02.03.2026'], + ]), + ]); + + $provider = new NbuCurrencyRateProvider; + $rate = $provider->findByCurrencyCode('GBP'); + + $this->assertNull($rate); + } + + public function test_get_all_returns_empty_array_on_api_failure(): void + { + Http::fake([ + 'bank.gov.ua/*' => Http::response([], 500), + ]); + + $provider = new NbuCurrencyRateProvider; + $rates = $provider->getAll(); + + $this->assertEmpty($rates); + } + + public function test_get_name_returns_provider_name(): void + { + $provider = new NbuCurrencyRateProvider; + + $this->assertEquals('NBU (National Bank of Ukraine)', $provider->getName()); + } +} diff --git a/tests/Unit/Currency/OpenExchangeRateProviderTest.php b/tests/Unit/Currency/OpenExchangeRateProviderTest.php new file mode 100644 index 0000000..0237d3a --- /dev/null +++ b/tests/Unit/Currency/OpenExchangeRateProviderTest.php @@ -0,0 +1,115 @@ + Http::response([ + 'result' => 'success', + 'time_last_update_unix' => 1709337600, + 'rates' => [ + 'UAH' => 40.0, + 'USD' => 1.0, + 'EUR' => 0.92, + 'GBP' => 0.79, + ], + ]), + ]); + + $provider = new OpenExchangeRateProvider; + $rates = $provider->getAll(); + + $this->assertNotEmpty($rates); + + $usdRate = collect($rates)->firstWhere('currencyCode', 'USD'); + $this->assertNotNull($usdRate); + $this->assertEquals('USD', $usdRate->currencyCode); + $this->assertEquals(40.0, $usdRate->rate); + + $eurRate = collect($rates)->firstWhere('currencyCode', 'EUR'); + $this->assertNotNull($eurRate); + $this->assertEquals('EUR', $eurRate->currencyCode); + $this->assertEqualsWithDelta(43.4783, $eurRate->rate, 0.01); + } + + public function test_find_by_currency_code_returns_correct_rate(): void + { + Http::fake([ + 'open.er-api.com/*' => Http::response([ + 'result' => 'success', + 'time_last_update_unix' => 1709337600, + 'rates' => [ + 'UAH' => 40.0, + 'USD' => 1.0, + 'EUR' => 0.92, + ], + ]), + ]); + + $provider = new OpenExchangeRateProvider; + $rate = $provider->findByCurrencyCode('USD'); + + $this->assertNotNull($rate); + $this->assertEquals('USD', $rate->currencyCode); + $this->assertEquals(40.0, $rate->rate); + } + + public function test_find_by_currency_code_returns_null_when_not_found(): void + { + Http::fake([ + 'open.er-api.com/*' => Http::response([ + 'result' => 'success', + 'time_last_update_unix' => 1709337600, + 'rates' => [ + 'UAH' => 40.0, + 'USD' => 1.0, + ], + ]), + ]); + + $provider = new OpenExchangeRateProvider; + $rate = $provider->findByCurrencyCode('GBP'); + + $this->assertNull($rate); + } + + public function test_get_all_returns_empty_array_on_api_failure(): void + { + Http::fake([ + 'open.er-api.com/*' => Http::response([], 500), + ]); + + $provider = new OpenExchangeRateProvider; + $rates = $provider->getAll(); + + $this->assertEmpty($rates); + } + + public function test_get_all_returns_empty_array_on_invalid_data(): void + { + Http::fake([ + 'open.er-api.com/*' => Http::response([ + 'result' => 'success', + ]), + ]); + + $provider = new OpenExchangeRateProvider; + $rates = $provider->getAll(); + + $this->assertEmpty($rates); + } + + public function test_get_name_returns_provider_name(): void + { + $provider = new OpenExchangeRateProvider; + + $this->assertEquals('Open Exchange Rates', $provider->getName()); + } +}