From 456a32fcb73ccdcd12e07ce0f4a65ddfc27a6e1c Mon Sep 17 00:00:00 2001 From: dtsemma Date: Sat, 14 Mar 2026 21:05:14 +0200 Subject: [PATCH 1/4] Added EUR support, fallback provider, caching, and currency tracking. --- README.md | 117 ++++++++++++---- app/Http/Controllers/CatalogController.php | 32 ++--- app/Http/Requests/CatalogRequest.php | 9 +- app/Jobs/TrackCatalogCurrencyUsage.php | 47 +++++++ app/Models/CatalogCurrencyUsage.php | 21 +++ .../Facades/CurrencyExchangeFacade.php | 67 +++++++-- .../CurrencyExchangeFacadeInterface.php | 29 ++++ .../Controllers/ExchangeRateController.php | 3 +- .../Http/Requests/ExchangeRateRequest.php | 16 ++- .../CurrencyRateProviderInterface.php | 25 ++++ .../Domain/CurrencyExchangeService.php | 30 ++-- .../ChainOfResponsibilityProvider.php | 102 ++++++++++++++ .../Decorators/CachedCurrencyRateProvider.php | 119 ++++++++++++++++ .../Providers/NbuCurrencyRateProvider.php | 76 ++++++++++ .../Providers/OpenExchangeRateProvider.php | 130 ++++++++++++++++++ .../Providers/CurrencyServiceProvider.php | 75 ++++++++-- .../CurrencyConversionServiceProvider.php | 28 ++++ app/Services/Catalog/CatalogService.php | 46 +++++++ .../Catalog/CatalogServiceInterface.php | 16 +++ .../CurrencyConversionStrategyInterface.php | 10 ++ .../CurrencyConversion/CurrencyConverter.php | 34 +++++ .../CurrencyConverterInterface.php | 16 +++ .../EurConversionStrategy.php | 40 ++++++ .../UahConversionStrategy.php | 40 ++++++ .../UsdConversionStrategy.php | 30 ++++ bootstrap/providers.php | 1 + config/currency.php | 84 +++++++++++ ...29_create_catalog_currency_usage_table.php | 34 +++++ phpunit.xml | 3 +- routes/api.php | 2 +- tests/Feature/CatalogCurrencyUsageTest.php | 106 ++++++++++++++ tests/Feature/CatalogTest.php | 27 +++- tests/Feature/CurrencyFallbackTest.php | 105 ++++++++++++++ tests/Feature/ExchangeRateTest.php | 35 +++++ .../CachedCurrencyRateProviderTest.php | 117 ++++++++++++++++ .../ChainOfResponsibilityProviderTest.php | 118 ++++++++++++++++ .../Currency/CurrencyExchangeServiceTest.php | 95 +++++++++++++ .../Currency/NbuCurrencyRateProviderTest.php | 79 +++++++++++ .../Currency/OpenExchangeRateProviderTest.php | 115 ++++++++++++++++ 39 files changed, 1997 insertions(+), 82 deletions(-) create mode 100644 app/Jobs/TrackCatalogCurrencyUsage.php create mode 100644 app/Models/CatalogCurrencyUsage.php create mode 100644 app/Modules/Currency/Application/Facades/CurrencyExchangeFacadeInterface.php create mode 100644 app/Modules/Currency/Domain/Contracts/CurrencyRateProviderInterface.php create mode 100644 app/Modules/Currency/Infrastructure/ChainProviders/ChainOfResponsibilityProvider.php create mode 100644 app/Modules/Currency/Infrastructure/Decorators/CachedCurrencyRateProvider.php create mode 100644 app/Modules/Currency/Infrastructure/Providers/NbuCurrencyRateProvider.php create mode 100644 app/Modules/Currency/Infrastructure/Providers/OpenExchangeRateProvider.php create mode 100644 app/Providers/CurrencyConversionServiceProvider.php create mode 100644 app/Services/Catalog/CatalogService.php create mode 100644 app/Services/Catalog/CatalogServiceInterface.php create mode 100644 app/Services/CurrencyConversion/CurrencyConversionStrategyInterface.php create mode 100644 app/Services/CurrencyConversion/CurrencyConverter.php create mode 100644 app/Services/CurrencyConversion/CurrencyConverterInterface.php create mode 100644 app/Services/CurrencyConversion/EurConversionStrategy.php create mode 100644 app/Services/CurrencyConversion/UahConversionStrategy.php create mode 100644 app/Services/CurrencyConversion/UsdConversionStrategy.php create mode 100644 config/currency.php create mode 100644 database/migrations/2026_03_12_183729_create_catalog_currency_usage_table.php create mode 100644 tests/Feature/CatalogCurrencyUsageTest.php create mode 100644 tests/Feature/CurrencyFallbackTest.php create mode 100644 tests/Unit/Currency/CachedCurrencyRateProviderTest.php create mode 100644 tests/Unit/Currency/ChainOfResponsibilityProviderTest.php create mode 100644 tests/Unit/Currency/CurrencyExchangeServiceTest.php create mode 100644 tests/Unit/Currency/NbuCurrencyRateProviderTest.php create mode 100644 tests/Unit/Currency/OpenExchangeRateProviderTest.php 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..9475f07 100644 --- a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php +++ b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php @@ -6,32 +6,79 @@ use Modules\Currency\Domain\CurrencyExchangeService; 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, ) {} - 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->service->findRateByCurrencyCode($currencyCode); } - public function convertFromUsdToUah(int $price): ConvertedPrice + /** + * Convert price from one currency to another. + * + * @param int|float $price Original price + * @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|float $price, string $fromCurrency, string $toCurrency): ConvertedPrice { - $usdRate = $this->service->findUsdRate(); + $fromCurrency = strtoupper($fromCurrency); + $toCurrency = strtoupper($toCurrency); - $convertedPrice = $price * $usdRate->rate; + if ($fromCurrency === $toCurrency) { + return new ConvertedPrice( + originalPrice: $price, + convertedPrice: $price, + fromCurrency: $fromCurrency, + toCurrency: $toCurrency, + rate: 1.0, + ); + } + + return $this->convertBetweenCurrencies($price, $fromCurrency, $toCurrency); + } + + private function convertBetweenCurrencies(int|float $price, string $fromCurrency, string $toCurrency): ConvertedPrice + { + $fromRate = $this->service->findRateByCurrencyCode($fromCurrency); + + if ($toCurrency === self::UAH_CODE) { + $convertedPrice = $price * $fromRate->rate; + + return new ConvertedPrice( + originalPrice: $price, + convertedPrice: round($convertedPrice, 2), + fromCurrency: $fromCurrency, + toCurrency: $toCurrency, + rate: $fromRate->rate, + ); + } + + $toRate = $this->service->findRateByCurrencyCode($toCurrency); + $uahPrice = $price * $fromRate->rate; + $convertedPrice = $uahPrice / $toRate->rate; return new ConvertedPrice( originalPrice: $price, convertedPrice: round($convertedPrice, 2), - fromCurrency: self::USD_CODE, - toCurrency: self::UAH_CODE, - rate: $usdRate->rate, + fromCurrency: $fromCurrency, + toCurrency: $toCurrency, + rate: $convertedPrice / $price, ); } } diff --git a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacadeInterface.php b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacadeInterface.php new file mode 100644 index 0000000..ab56182 --- /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/CurrencyRateProviderInterface.php b/app/Modules/Currency/Domain/Contracts/CurrencyRateProviderInterface.php new file mode 100644 index 0000000..b04ca07 --- /dev/null +++ b/app/Modules/Currency/Domain/Contracts/CurrencyRateProviderInterface.php @@ -0,0 +1,25 @@ +repository->getAll(); + return $this->provider->getAll(); } public function findUsdRate(): CurrencyRate { - foreach ($this->repository->getAll() as $currency) { - if ($currency->currencyCode === self::USD_CODE) { - return $currency; - } + return $this->findRateByCurrencyCode(self::USD_CODE); + } + + public function findEurRate(): CurrencyRate + { + return $this->findRateByCurrencyCode(self::EUR_CODE); + } + + 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..0847452 100644 --- a/app/Modules/Currency/Providers/CurrencyServiceProvider.php +++ b/app/Modules/Currency/Providers/CurrencyServiceProvider.php @@ -3,24 +3,83 @@ 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\CurrencyRateProviderInterface; +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(CurrencyRateProviderInterface::class, function ($app) { + $providers = $this->buildProviderChain(); + + $chainProvider = new ChainOfResponsibilityProvider($providers); + + if (config('currency.cache.enabled', true)) { + return new CachedCurrencyRateProvider($chainProvider); + } + + return $chainProvider; + }); + $this->app->singleton(CurrencyExchangeService::class, function ($app) { return new CurrencyExchangeService( - $app->make(NbuApiCurrencyRepository::class), + $app->make(CurrencyRateProviderInterface::class), ); }); - $this->app->singleton(CurrencyExchangeFacade::class, function ($app) { - return new CurrencyExchangeFacade( - $app->make(CurrencyExchangeService::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..e7af29b --- /dev/null +++ b/app/Services/Catalog/CatalogService.php @@ -0,0 +1,46 @@ + + */ + 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 + { + return [ + 'id' => $product['id'], + 'title' => $product['title'], + 'price' => $this->currencyConverter->convert($product['price'], $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..6e2f225 --- /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..7251af6 --- /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|float $price Original price + * @param string $currency Target currency code + * @return float Converted price + * @throws RuntimeException If no conversion strategy found for currency + */ + public function convert(int|float $price, string $currency): float + { + foreach ($this->strategies as $strategy) { + if ($strategy->supports($currency)) { + return $strategy->convert($price); + } + } + + 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..9826cbb --- /dev/null +++ b/app/Services/CurrencyConversion/CurrencyConverterInterface.php @@ -0,0 +1,16 @@ +currencyExchange->convert($price, self::FROM_CURRENCY, self::TO_CURRENCY); + + return $converted->convertedPrice; + } + + /** + * 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..1a63132 --- /dev/null +++ b/app/Services/CurrencyConversion/UahConversionStrategy.php @@ -0,0 +1,40 @@ +currencyExchange->convert($price, self::FROM_CURRENCY, self::TO_CURRENCY); + + return $converted->convertedPrice; + } + + /** + * 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..6b0fb81 --- /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..f288974 100644 --- a/tests/Feature/CatalogTest.php +++ b/tests/Feature/CatalogTest.php @@ -77,8 +77,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(round(199.99 * 40.0, 2), $data[0]['price']); + $this->assertEquals(round(299.99 * 40.0, 2), $data[1]['price']); } public function test_catalog_endpoint_does_not_require_authentication(): void @@ -91,11 +91,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->assertIsFloat($data[0]['price']); + $this->assertIsFloat($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..bad70f3 --- /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(4000.0, $data[0]['price']); + } + + 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()); + } +} From 9470cb4370304adc5f83a958e4602eaf0e262eb7 Mon Sep 17 00:00:00 2001 From: dtsemma Date: Sat, 14 Mar 2026 21:52:10 +0200 Subject: [PATCH 2/4] Refactoring --- .../Facades/CurrencyExchangeFacade.php | 6 +++--- .../CurrencyExchangeFacadeInterface.php | 4 ++-- app/Services/Catalog/CatalogService.php | 6 ++++-- .../Catalog/CatalogServiceInterface.php | 2 +- .../CurrencyConversionStrategyInterface.php | 2 +- .../CurrencyConversion/CurrencyConverter.php | 8 ++++---- .../CurrencyConverterInterface.php | 6 +++--- .../EurConversionStrategy.php | 11 ++++++----- .../UahConversionStrategy.php | 11 ++++++----- .../UsdConversionStrategy.php | 8 ++++---- tests/Feature/CatalogTest.php | 18 ++++++------------ tests/Feature/CurrencyFallbackTest.php | 2 +- 12 files changed, 41 insertions(+), 43 deletions(-) diff --git a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php index 9475f07..2493bc2 100644 --- a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php +++ b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php @@ -29,13 +29,13 @@ public function getRateByCurrencyCode(string $currencyCode): CurrencyRate /** * Convert price from one currency to another. * - * @param int|float $price Original price + * @param float $price Original price * @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|float $price, string $fromCurrency, string $toCurrency): ConvertedPrice + public function convert(float $price, string $fromCurrency, string $toCurrency): ConvertedPrice { $fromCurrency = strtoupper($fromCurrency); $toCurrency = strtoupper($toCurrency); @@ -53,7 +53,7 @@ public function convert(int|float $price, string $fromCurrency, string $toCurren return $this->convertBetweenCurrencies($price, $fromCurrency, $toCurrency); } - private function convertBetweenCurrencies(int|float $price, string $fromCurrency, string $toCurrency): ConvertedPrice + private function convertBetweenCurrencies(float $price, string $fromCurrency, string $toCurrency): ConvertedPrice { $fromRate = $this->service->findRateByCurrencyCode($fromCurrency); diff --git a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacadeInterface.php b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacadeInterface.php index ab56182..6fce15c 100644 --- a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacadeInterface.php +++ b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacadeInterface.php @@ -19,11 +19,11 @@ public function getRateByCurrencyCode(string $currencyCode): CurrencyRate; /** * Convert price from one currency to another. * - * @param int|float $price Original price + * @param float $price Original price * @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|float $price, string $fromCurrency, string $toCurrency): ConvertedPrice; + public function convert(float $price, string $fromCurrency, string $toCurrency): ConvertedPrice; } diff --git a/app/Services/Catalog/CatalogService.php b/app/Services/Catalog/CatalogService.php index e7af29b..0d271c3 100644 --- a/app/Services/Catalog/CatalogService.php +++ b/app/Services/Catalog/CatalogService.php @@ -20,7 +20,7 @@ public function __construct( * Get products from catalog with prices converted to specified currency. * * @param string $currency Target currency code (USD, UAH, EUR) - * @return Collection + * @return Collection */ public function getProducts(string $currency): Collection { @@ -35,10 +35,12 @@ public function getProducts(string $currency): Collection 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($product['price'], $currency), + '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 index 6e2f225..f40e8c7 100644 --- a/app/Services/Catalog/CatalogServiceInterface.php +++ b/app/Services/Catalog/CatalogServiceInterface.php @@ -10,7 +10,7 @@ interface CatalogServiceInterface * Get products from catalog with prices converted to specified currency. * * @param string $currency Target currency code (USD, UAH, EUR) - * @return Collection + * @return Collection */ public function getProducts(string $currency): Collection; } diff --git a/app/Services/CurrencyConversion/CurrencyConversionStrategyInterface.php b/app/Services/CurrencyConversion/CurrencyConversionStrategyInterface.php index 7251af6..31d673b 100644 --- a/app/Services/CurrencyConversion/CurrencyConversionStrategyInterface.php +++ b/app/Services/CurrencyConversion/CurrencyConversionStrategyInterface.php @@ -4,7 +4,7 @@ interface CurrencyConversionStrategyInterface { - public function convert(int|float $price): float; + public function convert(int $priceInCents): int; public function supports(string $currency): bool; } diff --git a/app/Services/CurrencyConversion/CurrencyConverter.php b/app/Services/CurrencyConversion/CurrencyConverter.php index d507f51..dfa878e 100644 --- a/app/Services/CurrencyConversion/CurrencyConverter.php +++ b/app/Services/CurrencyConversion/CurrencyConverter.php @@ -16,16 +16,16 @@ public function __construct( /** * Convert price from one currency to another. * - * @param int|float $price Original price + * @param int $priceInCents Original price in cents * @param string $currency Target currency code - * @return float Converted price + * @return int Converted price in cents * @throws RuntimeException If no conversion strategy found for currency */ - public function convert(int|float $price, string $currency): float + public function convert(int $priceInCents, string $currency): int { foreach ($this->strategies as $strategy) { if ($strategy->supports($currency)) { - return $strategy->convert($price); + return $strategy->convert($priceInCents); } } diff --git a/app/Services/CurrencyConversion/CurrencyConverterInterface.php b/app/Services/CurrencyConversion/CurrencyConverterInterface.php index 9826cbb..15c421a 100644 --- a/app/Services/CurrencyConversion/CurrencyConverterInterface.php +++ b/app/Services/CurrencyConversion/CurrencyConverterInterface.php @@ -7,10 +7,10 @@ interface CurrencyConverterInterface /** * Convert price from one currency to another. * - * @param int|float $price Original price + * @param int $priceInCents Original price in cents * @param string $currency Target currency code - * @return float Converted price + * @return int Converted price in cents * @throws \RuntimeException If no conversion strategy found for currency */ - public function convert(int|float $price, string $currency): float; + public function convert(int $priceInCents, string $currency): int; } diff --git a/app/Services/CurrencyConversion/EurConversionStrategy.php b/app/Services/CurrencyConversion/EurConversionStrategy.php index f946eb2..f9025bc 100644 --- a/app/Services/CurrencyConversion/EurConversionStrategy.php +++ b/app/Services/CurrencyConversion/EurConversionStrategy.php @@ -17,14 +17,15 @@ public function __construct( /** * Convert USD price to EUR. * - * @param int|float $price Price in USD - * @return float Price in EUR + * @param int $priceInCents Price in USD cents + * @return int Price in EUR cents */ - public function convert(int|float $price): float + public function convert(int $priceInCents): int { - $converted = $this->currencyExchange->convert($price, self::FROM_CURRENCY, self::TO_CURRENCY); + $priceInDollars = $priceInCents / 100; + $converted = $this->currencyExchange->convert($priceInDollars, self::FROM_CURRENCY, self::TO_CURRENCY); - return $converted->convertedPrice; + return (int) round($converted->convertedPrice * 100); } /** diff --git a/app/Services/CurrencyConversion/UahConversionStrategy.php b/app/Services/CurrencyConversion/UahConversionStrategy.php index 1a63132..515c83e 100644 --- a/app/Services/CurrencyConversion/UahConversionStrategy.php +++ b/app/Services/CurrencyConversion/UahConversionStrategy.php @@ -17,14 +17,15 @@ public function __construct( /** * Convert USD price to UAH. * - * @param int|float $price Price in USD - * @return float Price in UAH + * @param int $priceInCents Price in USD cents + * @return int Price in UAH kopiykas */ - public function convert(int|float $price): float + public function convert(int $priceInCents): int { - $converted = $this->currencyExchange->convert($price, self::FROM_CURRENCY, self::TO_CURRENCY); + $priceInDollars = $priceInCents / 100; + $converted = $this->currencyExchange->convert($priceInDollars, self::FROM_CURRENCY, self::TO_CURRENCY); - return $converted->convertedPrice; + return (int) round($converted->convertedPrice * 100); } /** diff --git a/app/Services/CurrencyConversion/UsdConversionStrategy.php b/app/Services/CurrencyConversion/UsdConversionStrategy.php index 6b0fb81..c61730c 100644 --- a/app/Services/CurrencyConversion/UsdConversionStrategy.php +++ b/app/Services/CurrencyConversion/UsdConversionStrategy.php @@ -9,12 +9,12 @@ class UsdConversionStrategy implements CurrencyConversionStrategyInterface /** * Convert price (no conversion needed for USD). * - * @param int|float $price Price in USD - * @return float Price in USD + * @param int $priceInCents Price in USD cents + * @return int Price in USD cents */ - public function convert(int|float $price): float + public function convert(int $priceInCents): int { - return (float) $price; + return $priceInCents; } /** diff --git a/tests/Feature/CatalogTest.php b/tests/Feature/CatalogTest.php index f288974..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(round(199.99 * 40.0, 2), $data[0]['price']); - $this->assertEquals(round(299.99 * 40.0, 2), $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 @@ -113,8 +107,8 @@ public function test_catalog_eur_returns_converted_price(): void ]); $data = $response->json('data'); - $this->assertIsFloat($data[0]['price']); - $this->assertIsFloat($data[1]['price']); + $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 index bad70f3..5ccb0aa 100644 --- a/tests/Feature/CurrencyFallbackTest.php +++ b/tests/Feature/CurrencyFallbackTest.php @@ -88,7 +88,7 @@ public function test_catalog_works_with_fallback_provider(): void $response->assertStatus(200); $data = $response->json('data'); - $this->assertEquals(4000.0, $data[0]['price']); + $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 From 6c2e909c445f31d72f78d53150298aa04b0febc6 Mon Sep 17 00:00:00 2001 From: dtsemma Date: Sat, 14 Mar 2026 22:00:30 +0200 Subject: [PATCH 3/4] Fixed type for price --- .../Facades/CurrencyExchangeFacade.php | 23 ++++++++++--------- .../CurrencyExchangeFacadeInterface.php | 4 ++-- .../EurConversionStrategy.php | 3 +-- .../UahConversionStrategy.php | 3 +-- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php index 2493bc2..089266b 100644 --- a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php +++ b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php @@ -29,39 +29,40 @@ public function getRateByCurrencyCode(string $currencyCode): CurrencyRate /** * Convert price from one currency to another. * - * @param float $price Original price + * @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(float $price, string $fromCurrency, string $toCurrency): ConvertedPrice + public function convert(int $priceInCents, string $fromCurrency, string $toCurrency): ConvertedPrice { $fromCurrency = strtoupper($fromCurrency); $toCurrency = strtoupper($toCurrency); if ($fromCurrency === $toCurrency) { return new ConvertedPrice( - originalPrice: $price, - convertedPrice: $price, + originalPrice: $priceInCents / 100, + convertedPrice: $priceInCents / 100, fromCurrency: $fromCurrency, toCurrency: $toCurrency, rate: 1.0, ); } - return $this->convertBetweenCurrencies($price, $fromCurrency, $toCurrency); + return $this->convertBetweenCurrencies($priceInCents, $fromCurrency, $toCurrency); } - private function convertBetweenCurrencies(float $price, string $fromCurrency, string $toCurrency): ConvertedPrice + private function convertBetweenCurrencies(int $priceInCents, string $fromCurrency, string $toCurrency): ConvertedPrice { + $priceInDollars = $priceInCents / 100; $fromRate = $this->service->findRateByCurrencyCode($fromCurrency); if ($toCurrency === self::UAH_CODE) { - $convertedPrice = $price * $fromRate->rate; + $convertedPrice = $priceInDollars * $fromRate->rate; return new ConvertedPrice( - originalPrice: $price, + originalPrice: $priceInDollars, convertedPrice: round($convertedPrice, 2), fromCurrency: $fromCurrency, toCurrency: $toCurrency, @@ -70,15 +71,15 @@ private function convertBetweenCurrencies(float $price, string $fromCurrency, st } $toRate = $this->service->findRateByCurrencyCode($toCurrency); - $uahPrice = $price * $fromRate->rate; + $uahPrice = $priceInDollars * $fromRate->rate; $convertedPrice = $uahPrice / $toRate->rate; return new ConvertedPrice( - originalPrice: $price, + originalPrice: $priceInDollars, convertedPrice: round($convertedPrice, 2), fromCurrency: $fromCurrency, toCurrency: $toCurrency, - rate: $convertedPrice / $price, + rate: $convertedPrice / $priceInDollars, ); } } diff --git a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacadeInterface.php b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacadeInterface.php index 6fce15c..8dcf473 100644 --- a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacadeInterface.php +++ b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacadeInterface.php @@ -19,11 +19,11 @@ public function getRateByCurrencyCode(string $currencyCode): CurrencyRate; /** * Convert price from one currency to another. * - * @param float $price Original price + * @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(float $price, string $fromCurrency, string $toCurrency): ConvertedPrice; + public function convert(int $priceInCents, string $fromCurrency, string $toCurrency): ConvertedPrice; } diff --git a/app/Services/CurrencyConversion/EurConversionStrategy.php b/app/Services/CurrencyConversion/EurConversionStrategy.php index f9025bc..8509d51 100644 --- a/app/Services/CurrencyConversion/EurConversionStrategy.php +++ b/app/Services/CurrencyConversion/EurConversionStrategy.php @@ -22,8 +22,7 @@ public function __construct( */ public function convert(int $priceInCents): int { - $priceInDollars = $priceInCents / 100; - $converted = $this->currencyExchange->convert($priceInDollars, self::FROM_CURRENCY, self::TO_CURRENCY); + $converted = $this->currencyExchange->convert($priceInCents, self::FROM_CURRENCY, self::TO_CURRENCY); return (int) round($converted->convertedPrice * 100); } diff --git a/app/Services/CurrencyConversion/UahConversionStrategy.php b/app/Services/CurrencyConversion/UahConversionStrategy.php index 515c83e..f482e93 100644 --- a/app/Services/CurrencyConversion/UahConversionStrategy.php +++ b/app/Services/CurrencyConversion/UahConversionStrategy.php @@ -22,8 +22,7 @@ public function __construct( */ public function convert(int $priceInCents): int { - $priceInDollars = $priceInCents / 100; - $converted = $this->currencyExchange->convert($priceInDollars, self::FROM_CURRENCY, self::TO_CURRENCY); + $converted = $this->currencyExchange->convert($priceInCents, self::FROM_CURRENCY, self::TO_CURRENCY); return (int) round($converted->convertedPrice * 100); } From fa9a12bc18ce7dc47990c0c5db5614d821de1045 Mon Sep 17 00:00:00 2001 From: dtsemma Date: Sat, 14 Mar 2026 22:10:32 +0200 Subject: [PATCH 4/4] Refactored Facade and added Interfaces --- .../Facades/CurrencyExchangeFacade.php | 55 ++---------- .../Contracts/CurrencyConverterInterface.php | 19 ++++ .../CurrencyExchangeServiceInterface.php | 40 +++++++++ .../Currency/Domain/CurrencyConverter.php | 87 +++++++++++++++++++ .../Domain/CurrencyExchangeService.php | 24 ++++- .../Providers/CurrencyServiceProvider.php | 11 +-- 6 files changed, 181 insertions(+), 55 deletions(-) create mode 100644 app/Modules/Currency/Domain/Contracts/CurrencyConverterInterface.php create mode 100644 app/Modules/Currency/Domain/Contracts/CurrencyExchangeServiceInterface.php create mode 100644 app/Modules/Currency/Domain/CurrencyConverter.php diff --git a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php index 089266b..fa6aa9b 100644 --- a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php +++ b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php @@ -3,15 +3,15 @@ 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 implements CurrencyExchangeFacadeInterface { - private const string UAH_CODE = 'UAH'; - public function __construct( - private readonly CurrencyExchangeService $service, + private readonly CurrencyExchangeServiceInterface $exchangeService, + private readonly CurrencyConverterInterface $currencyConverter, ) {} /** @@ -23,7 +23,7 @@ public function __construct( */ public function getRateByCurrencyCode(string $currencyCode): CurrencyRate { - return $this->service->findRateByCurrencyCode($currencyCode); + return $this->exchangeService->findRateByCurrencyCode($currencyCode); } /** @@ -37,49 +37,6 @@ public function getRateByCurrencyCode(string $currencyCode): CurrencyRate */ public function convert(int $priceInCents, string $fromCurrency, string $toCurrency): ConvertedPrice { - $fromCurrency = strtoupper($fromCurrency); - $toCurrency = strtoupper($toCurrency); - - if ($fromCurrency === $toCurrency) { - return new ConvertedPrice( - originalPrice: $priceInCents / 100, - convertedPrice: $priceInCents / 100, - fromCurrency: $fromCurrency, - toCurrency: $toCurrency, - rate: 1.0, - ); - } - - return $this->convertBetweenCurrencies($priceInCents, $fromCurrency, $toCurrency); - } - - private function convertBetweenCurrencies(int $priceInCents, string $fromCurrency, string $toCurrency): ConvertedPrice - { - $priceInDollars = $priceInCents / 100; - $fromRate = $this->service->findRateByCurrencyCode($fromCurrency); - - if ($toCurrency === self::UAH_CODE) { - $convertedPrice = $priceInDollars * $fromRate->rate; - - return new ConvertedPrice( - originalPrice: $priceInDollars, - convertedPrice: round($convertedPrice, 2), - fromCurrency: $fromCurrency, - toCurrency: $toCurrency, - rate: $fromRate->rate, - ); - } - - $toRate = $this->service->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, - ); + return $this->currencyConverter->convert($priceInCents, $fromCurrency, $toCurrency); } } 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 c585197..8aac5fd 100644 --- a/app/Modules/Currency/Domain/CurrencyExchangeService.php +++ b/app/Modules/Currency/Domain/CurrencyExchangeService.php @@ -2,9 +2,10 @@ namespace Modules\Currency\Domain; +use Modules\Currency\Domain\Contracts\CurrencyExchangeServiceInterface; use Modules\Currency\Domain\Contracts\CurrencyRateProviderInterface; -class CurrencyExchangeService +class CurrencyExchangeService implements CurrencyExchangeServiceInterface { private const string USD_CODE = 'USD'; @@ -15,6 +16,8 @@ public function __construct( ) {} /** + * Get all available currency rates. + * * @return CurrencyRate[] */ public function getRates(): array @@ -22,16 +25,35 @@ public function getRates(): array return $this->provider->getAll(); } + /** + * Find USD exchange rate. + * + * @return CurrencyRate USD rate information + * @throws \RuntimeException If USD rate not found + */ public function findUsdRate(): CurrencyRate { 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); diff --git a/app/Modules/Currency/Providers/CurrencyServiceProvider.php b/app/Modules/Currency/Providers/CurrencyServiceProvider.php index 0847452..857179e 100644 --- a/app/Modules/Currency/Providers/CurrencyServiceProvider.php +++ b/app/Modules/Currency/Providers/CurrencyServiceProvider.php @@ -5,7 +5,10 @@ use Illuminate\Support\ServiceProvider; use Modules\Currency\Application\Facades\CurrencyExchangeFacade; 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; @@ -26,11 +29,9 @@ public function register(): void return $chainProvider; }); - $this->app->singleton(CurrencyExchangeService::class, function ($app) { - return new CurrencyExchangeService( - $app->make(CurrencyRateProviderInterface::class), - ); - }); + $this->app->singleton(CurrencyExchangeServiceInterface::class, CurrencyExchangeService::class); + + $this->app->singleton(CurrencyConverterInterface::class, CurrencyConverter::class); $this->app->singleton(CurrencyExchangeFacadeInterface::class, CurrencyExchangeFacade::class); }