diff --git a/.env.example b/.env.example index c0660ea..4f121b3 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,13 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +CURRENCY_PROVIDER=nbu +CURRENCY_NBU_API_URL=https://bank.gov.ua/NBUStatService/v1/statdirectory/exchange +CURRENCY_FALLBACK_PROVIDER=open_er_api +CURRENCY_OPEN_ER_API_URL=https://open.er-api.com/v6/latest/UAH + +CURRENCY_CACHE_TTL_MINUTES=10 +CURRENCY_TIMEOUT_SECONDS=5 + +DUMMYJSON_BASE_URL=https://dummyjson.com diff --git a/README.md b/README.md index 2606a8e..6b1465c 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, UAH, EUR) using exchange-rate providers (NBU as primary, Open ER API as fallback) with caching. ## 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) or UAH/EUR (converted via exchange-rate providers) +- Retrieve the current USD/UAH and EUR/UAH exchange rates +- Modular domain-driven architecture for the Currency module with pluggable providers ## Tech Stack @@ -24,7 +24,7 @@ Returns a list of smartphones with prices in the requested currency. | Parameter | Type | Allowed Values | Description | |------------|--------|----------------|--------------------------------------| -| `currency` | string | `usd`, `uah` | Currency for product prices | +| `currency` | string | `usd`, `uah`, `eur` | Currency for product prices | **Response (200):** @@ -44,9 +44,9 @@ Returns a list of smartphones with prices in the requested currency. Unsupported currencies return **404**. -### `GET /api/exchangeRate/USD` +### `GET /api/exchangeRate/{currency}` -Returns the current USD to UAH exchange rate from the NBU. +Returns the current exchange rate of the given currency to UAH (currently supports `USD` and `EUR`). **Response (200):** @@ -66,29 +66,39 @@ Returns the current USD to UAH exchange rate from the NBU. 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 # Tracks catalog usage in background ├── Models/ -│ └── User.php +│ └── CatalogCurrencyUsage.php # Catalog currency usage entries └── Modules/ └── Currency/ ├── Application/ │ ├── Facades/ - │ │ └── CurrencyExchangeFacade.php # Entry point for currency operations + │ │ └── CurrencyExchangeFacade.php # Entry point for currency operations │ └── Http/ │ ├── Controllers/ │ │ └── ExchangeRateController.php │ └── Requests/ │ └── ExchangeRateRequest.php ├── Domain/ - │ ├── ConvertedPrice.php # DTO for converted price data - │ ├── CurrencyExchangeService.php # Core exchange logic - │ └── CurrencyRate.php # DTO for rate data + │ ├── Contracts/ + │ │ └── ExchangeRateProviderInterface.php # Abstraction for rate providers + │ ├── Enums/ + │ │ └── CurrencyCode.php # Supported currency codes + │ ├── ConvertedPrice.php # DTO for converted price data + │ ├── CurrencyExchangeService.php # Core exchange logic + │ └── CurrencyRate.php # DTO for rate data ├── Infrastructure/ - │ └── NbuApiCurrencyRepository.php # NBU API integration + │ └── Providers/ + │ ├── NbuExchangeRateProvider.php # NBU API integration (primary) + │ ├── OpenErApiExchangeRateProvider.php # Open ER API integration (fallback) + │ ├── FailoverExchangeRateProvider.php # Primary + fallback composition + │ └── CachedExchangeRateProvider.php # Caching decorator └── Providers/ - └── CurrencyServiceProvider.php # DI bindings + └── CurrencyServiceProvider.php # DI bindings ``` ## Getting Started @@ -145,13 +155,15 @@ Tests use `Http::fake()` to mock external API calls (DummyJSON and NBU), so no n ### Test Coverage -- **Catalog endpoint** — USD/UAH responses, price conversion, currency validation, HTTP method restrictions -- **Exchange rate endpoint** — JSON structure, rate values, HTTP method restrictions -- Both endpoints verified to not require authentication +- **Catalog endpoint** — USD/UAH/EUR responses, price conversion, currency validation, HTTP method restrictions +- **Exchange rate endpoint** — USD/EUR JSON structure, rate values, HTTP method restrictions +- **Currency providers** — conversion logic, failover and caching behavior +- **Catalog usage tracking** — job execution and database records ## External APIs | 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 | Exchange rates (primary) | `https://bank.gov.ua/NBUStatService/v1/statdirectory/exchange` | +| Open ER API | Exchange rates (fallback) | `https://open.er-api.com/v6/latest/UAH` | diff --git a/app/Actions/GetCatalogAction.php b/app/Actions/GetCatalogAction.php new file mode 100644 index 0000000..81188cc --- /dev/null +++ b/app/Actions/GetCatalogAction.php @@ -0,0 +1,43 @@ +value, now()->toIso8601String()); + + $products = $this->catalogClient->getSmartphones(self::DEFAULT_LIMIT); + + return collect($products) + ->map(function (array $product) use ($currencyCode) { + $convertedPrice = $this->currencyExchange->convertFromUsdTo( + $currencyCode, + (float) $product['price'], + ); + + return [ + 'id' => $product['id'], + 'title' => $product['title'], + 'price' => $convertedPrice->convertedPrice, + 'rating' => $product['rating'], + 'thumbnail' => $product['thumbnail'], + ]; + }) + ->values() + ->all(); + } +} diff --git a/app/Http/Controllers/CatalogController.php b/app/Http/Controllers/CatalogController.php index 57fb8a2..1cc72fe 100644 --- a/app/Http/Controllers/CatalogController.php +++ b/app/Http/Controllers/CatalogController.php @@ -2,41 +2,25 @@ namespace App\Http\Controllers; +use App\Actions\GetCatalogAction; use App\Http\Requests\CatalogRequest; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\Http; -use Modules\Currency\Application\Facades\CurrencyExchangeFacade; +use Modules\Currency\Domain\Enums\CurrencyCode; class CatalogController extends Controller { public function __construct( - private readonly CurrencyExchangeFacade $currencyExchange, + private readonly GetCatalogAction $getCatalogAction, ) {} public function __invoke(CatalogRequest $request): JsonResponse { - $currency = $request->route('currency'); - $response = Http::get('https://dummyjson.com/products/category/smartphones', [ - 'limit' => 5, - ]); - - $products = collect($response->json('products'))->map(function (array $product) use ($currency) { - $price = $product['price']; + $currencyCode = CurrencyCode::from(strtoupper($request->route('currency'))); - if (strtoupper($currency) === 'UAH') { - $converted = $this->currencyExchange->convertFromUsdToUah((int) $price); - $price = $converted->convertedPrice; - } + $products = $this->getCatalogAction->handle($currencyCode); - return [ - 'id' => $product['id'], - 'title' => $product['title'], - 'price' => $price, - 'rating' => $product['rating'], - 'thumbnail' => $product['thumbnail'], - ]; - }); - - return response()->json(['data' => $products]); + return response()->json([ + 'data' => $products, + ]); } } diff --git a/app/Http/Requests/CatalogRequest.php b/app/Http/Requests/CatalogRequest.php index f3ee2d8..fa80e9f 100644 --- a/app/Http/Requests/CatalogRequest.php +++ b/app/Http/Requests/CatalogRequest.php @@ -5,6 +5,7 @@ use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Modules\Currency\Domain\Enums\CurrencyCode; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class CatalogRequest extends FormRequest @@ -12,7 +13,7 @@ class CatalogRequest extends FormRequest public function rules(): array { return [ - 'currency' => ['required', 'string', Rule::in(['uah', 'usd'])], + 'currency' => ['required', 'string', Rule::in(CurrencyCode::catalogSupported())], ]; } diff --git a/app/Jobs/TrackCatalogCurrencyUsage.php b/app/Jobs/TrackCatalogCurrencyUsage.php new file mode 100644 index 0000000..26db7ac --- /dev/null +++ b/app/Jobs/TrackCatalogCurrencyUsage.php @@ -0,0 +1,29 @@ + $this->currency, + 'requested_at' => CarbonImmutable::parse($this->requestedAt), + ]); + } +} diff --git a/app/Models/CatalogCurrencyUsage.php b/app/Models/CatalogCurrencyUsage.php new file mode 100644 index 0000000..7c713bd --- /dev/null +++ b/app/Models/CatalogCurrencyUsage.php @@ -0,0 +1,22 @@ + 'datetime', + ]; +} diff --git a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php index 2e62b92..17b6931 100644 --- a/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php +++ b/app/Modules/Currency/Application/Facades/CurrencyExchangeFacade.php @@ -5,33 +5,21 @@ use Modules\Currency\Domain\ConvertedPrice; use Modules\Currency\Domain\CurrencyExchangeService; use Modules\Currency\Domain\CurrencyRate; +use Modules\Currency\Domain\Enums\CurrencyCode; class CurrencyExchangeFacade { - private const string USD_CODE = 'USD'; - private const string UAH_CODE = 'UAH'; - public function __construct( private readonly CurrencyExchangeService $service, ) {} - public function getUsdRate(): CurrencyRate + public function getRate(CurrencyCode $currency): CurrencyRate { - return $this->service->findUsdRate(); + return $this->service->getRate($currency); } - public function convertFromUsdToUah(int $price): ConvertedPrice + public function convertFromUsdTo(CurrencyCode $currency, float $price): 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->service->convertUsdTo($currency, $price); } } diff --git a/app/Modules/Currency/Application/Http/Controllers/ExchangeRateController.php b/app/Modules/Currency/Application/Http/Controllers/ExchangeRateController.php index 3865b97..765c6f2 100644 --- a/app/Modules/Currency/Application/Http/Controllers/ExchangeRateController.php +++ b/app/Modules/Currency/Application/Http/Controllers/ExchangeRateController.php @@ -6,6 +6,7 @@ use Illuminate\Http\JsonResponse; use Modules\Currency\Application\Facades\CurrencyExchangeFacade; use Modules\Currency\Application\Http\Requests\ExchangeRateRequest; +use Modules\Currency\Domain\Enums\CurrencyCode; class ExchangeRateController extends Controller { @@ -15,7 +16,9 @@ public function __construct( public function __invoke(ExchangeRateRequest $request): JsonResponse { - $rate = $this->currencyExchange->getUsdRate(); + $currencyCode = CurrencyCode::from(strtoupper($request->route('currency'))); + + $rate = $this->currencyExchange->getRate($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..998cdf4 100644 --- a/app/Modules/Currency/Application/Http/Requests/ExchangeRateRequest.php +++ b/app/Modules/Currency/Application/Http/Requests/ExchangeRateRequest.php @@ -3,11 +3,22 @@ namespace Modules\Currency\Application\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; +use Modules\Currency\Domain\Enums\CurrencyCode; class ExchangeRateRequest extends FormRequest { public function rules(): array { - return []; + return [ + 'currency' => ['required', 'string', Rule::in(CurrencyCode::exchangeRateSupported())], + ]; + } + + public function validationData(): array + { + return array_merge(parent::validationData(), [ + 'currency' => strtolower($this->route('currency')), + ]); } } diff --git a/app/Modules/Currency/Domain/Contracts/ExchangeRateProviderInterface.php b/app/Modules/Currency/Domain/Contracts/ExchangeRateProviderInterface.php new file mode 100644 index 0000000..5c813d3 --- /dev/null +++ b/app/Modules/Currency/Domain/Contracts/ExchangeRateProviderInterface.php @@ -0,0 +1,15 @@ +repository->getAll(); + return $this->provider->getAll(); + } + + public function getRate(CurrencyCode $currency): CurrencyRate + { + return $this->provider->getRate($currency->value); } - public function findUsdRate(): CurrencyRate + public function convertUsdTo(CurrencyCode $target, float $usdPrice): ConvertedPrice { - foreach ($this->repository->getAll() as $currency) { - if ($currency->currencyCode === self::USD_CODE) { - return $currency; - } + if ($target === CurrencyCode::USD) { + return new ConvertedPrice( + originalPrice: $usdPrice, + convertedPrice: round($usdPrice, 2), + fromCurrency: CurrencyCode::USD->value, + toCurrency: CurrencyCode::USD->value, + rate: 1.0, + ); } - throw new \RuntimeException('USD rate not found in exchange rates'); + $usdToUahRate = $this->getRate(CurrencyCode::USD)->rate; + $uahPrice = $usdPrice * $usdToUahRate; + + $targetToUahRate = $target === CurrencyCode::UAH + ? 1.0 + : $this->getRate($target)->rate; + + $convertedPrice = $uahPrice / $targetToUahRate; + $effectiveRate = $usdToUahRate / $targetToUahRate; + + return new ConvertedPrice( + originalPrice: $usdPrice, + convertedPrice: round($convertedPrice, 2), + fromCurrency: CurrencyCode::USD->value, + toCurrency: $target->value, + rate: $effectiveRate, + ); } } diff --git a/app/Modules/Currency/Domain/Enums/CurrencyCode.php b/app/Modules/Currency/Domain/Enums/CurrencyCode.php new file mode 100644 index 0000000..fdc0944 --- /dev/null +++ b/app/Modules/Currency/Domain/Enums/CurrencyCode.php @@ -0,0 +1,27 @@ +value), + strtolower(self::UAH->value), + strtolower(self::EUR->value), + ]; + } + + public static function exchangeRateSupported(): array + { + return [ + strtolower(self::USD->value), + strtolower(self::EUR->value), + ]; + } +} diff --git a/app/Modules/Currency/Domain/Exceptions/ExchangeRateProviderException.php b/app/Modules/Currency/Domain/Exceptions/ExchangeRateProviderException.php new file mode 100644 index 0000000..b57a2bc --- /dev/null +++ b/app/Modules/Currency/Domain/Exceptions/ExchangeRateProviderException.php @@ -0,0 +1,9 @@ + '']); - - return array_map( - fn (array $item) => new CurrencyRate( - name: $item['txt'], - rate: (float) $item['rate'], - currencyCode: $item['cc'], - exchangeDate: $item['exchangedate'], - ), - $response->json(), - ); - } -} diff --git a/app/Modules/Currency/Infrastructure/Providers/CachedExchangeRateProvider.php b/app/Modules/Currency/Infrastructure/Providers/CachedExchangeRateProvider.php new file mode 100644 index 0000000..06e4dc1 --- /dev/null +++ b/app/Modules/Currency/Infrastructure/Providers/CachedExchangeRateProvider.php @@ -0,0 +1,45 @@ +cacheKey(); + $ttlMinutes = (int) config('currency.cache_ttl_minutes', 10); + + return Cache::remember( + $cacheKey, + now()->addMinutes($ttlMinutes), + fn () => $this->inner->getAll(), + ); + } + + public function getRate(string $currencyCode): CurrencyRate + { + foreach ($this->getAll() as $currencyRate) { + if ($currencyRate->currencyCode === $currencyCode) { + return $currencyRate; + } + } + + throw new \RuntimeException(sprintf('Exchange rate for %s not found in cached rates', $currencyCode)); + } + + private function cacheKey(): string + { + return sprintf('currency:rates:%s', now()->format('Y-m-d')); + } +} diff --git a/app/Modules/Currency/Infrastructure/Providers/FailoverExchangeRateProvider.php b/app/Modules/Currency/Infrastructure/Providers/FailoverExchangeRateProvider.php new file mode 100644 index 0000000..933dec8 --- /dev/null +++ b/app/Modules/Currency/Infrastructure/Providers/FailoverExchangeRateProvider.php @@ -0,0 +1,36 @@ +primary->getAll(); + } catch (ExchangeRateProviderException) { + return $this->fallback->getAll(); + } + } + + public function getRate(string $currencyCode): CurrencyRate + { + try { + return $this->primary->getRate($currencyCode); + } catch (ExchangeRateProviderException) { + return $this->fallback->getRate($currencyCode); + } + } +} diff --git a/app/Modules/Currency/Infrastructure/Providers/NbuExchangeRateProvider.php b/app/Modules/Currency/Infrastructure/Providers/NbuExchangeRateProvider.php new file mode 100644 index 0000000..0af9f9e --- /dev/null +++ b/app/Modules/Currency/Infrastructure/Providers/NbuExchangeRateProvider.php @@ -0,0 +1,70 @@ +get(config('currency.api_urls.nbu'), ['json' => '']) + ->throw(); + } catch (\Throwable $exception) { + throw new ExchangeRateProviderException( + 'Failed to fetch exchange rates from NBU provider.', + previous: $exception, + ); + } + + $data = $response->json(); + + if (! is_array($data)) { + throw new ExchangeRateProviderException( + 'Invalid response from NBU exchange API.' + ); + } + + return array_map( + fn (array $item): CurrencyRate => $this->mapRate($item), + $data + ); + } + + public function getRate(string $currencyCode): CurrencyRate + { + foreach ($this->getAll() as $currencyRate) { + if ($currencyRate->currencyCode === $currencyCode) { + return $currencyRate; + } + } + + throw new ExchangeRateProviderException( + sprintf('Exchange rate for %s not found in NBU rates.', $currencyCode) + ); + } + + private function mapRate(array $item): CurrencyRate + { + if (! isset($item['txt'], $item['rate'], $item['cc'], $item['exchangedate'])) { + throw new ExchangeRateProviderException( + 'Incomplete currency item received from NBU exchange API.' + ); + } + + return new CurrencyRate( + name: $item['txt'], + rate: (float) $item['rate'], + currencyCode: $item['cc'], + exchangeDate: $item['exchangedate'], + ); + } +} diff --git a/app/Modules/Currency/Infrastructure/Providers/OpenErApiExchangeRateProvider.php b/app/Modules/Currency/Infrastructure/Providers/OpenErApiExchangeRateProvider.php new file mode 100644 index 0000000..3ae2a73 --- /dev/null +++ b/app/Modules/Currency/Infrastructure/Providers/OpenErApiExchangeRateProvider.php @@ -0,0 +1,88 @@ +get(config('currency.api_urls.open_er_api')) + ->throw(); + } catch (\Throwable $exception) { + throw new ExchangeRateProviderException( + 'Failed to fetch exchange rates from Open ER API provider.', + previous: $exception, + ); + } + + $data = $response->json(); + + if (! is_array($data)) { + throw new ExchangeRateProviderException( + 'Invalid response from Open ER API.' + ); + } + + if (($data['result'] ?? null) !== 'success') { + throw new ExchangeRateProviderException( + 'Open ER API returned unsuccessful result.' + ); + } + + $rates = $data['rates'] ?? null; + + if (! is_array($rates)) { + throw new ExchangeRateProviderException( + 'Invalid rates structure from Open ER API.' + ); + } + + $date = $data['time_last_update_utc'] ?? null; + + $exchangeDate = $date + ? date('d.m.Y', strtotime($date)) + : now()->format('d.m.Y'); + + $currencies = []; + + foreach ($rates as $code => $rate) { + if (! is_string($code) || ! is_numeric($rate) || (float) $rate <= 0) { + continue; + } + + // API base is UAH, so rates are returned as "target currency per 1 UAH". + // We invert them to normalize all providers to "1 unit of currency in UAH". + $currencies[] = new CurrencyRate( + name: $code, + rate: 1 / (float) $rate, + currencyCode: $code, + exchangeDate: $exchangeDate, + ); + } + + return $currencies; + } + + public function getRate(string $currencyCode): CurrencyRate + { + foreach ($this->getAll() as $currencyRate) { + if ($currencyRate->currencyCode === $currencyCode) { + return $currencyRate; + } + } + + throw new ExchangeRateProviderException( + sprintf('Exchange rate for %s not found in Open ER API rates.', $currencyCode) + ); + } +} diff --git a/app/Modules/Currency/Providers/CurrencyServiceProvider.php b/app/Modules/Currency/Providers/CurrencyServiceProvider.php index cd7ad9f..5232cef 100644 --- a/app/Modules/Currency/Providers/CurrencyServiceProvider.php +++ b/app/Modules/Currency/Providers/CurrencyServiceProvider.php @@ -2,25 +2,75 @@ namespace Modules\Currency\Providers; +use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\ServiceProvider; -use Modules\Currency\Domain\CurrencyExchangeService; use Modules\Currency\Application\Facades\CurrencyExchangeFacade; -use Modules\Currency\Infrastructure\NbuApiCurrencyRepository; +use Modules\Currency\Domain\Contracts\ExchangeRateProviderInterface; +use Modules\Currency\Domain\CurrencyExchangeService; +use Modules\Currency\Infrastructure\Providers\CachedExchangeRateProvider; +use Modules\Currency\Infrastructure\Providers\FailoverExchangeRateProvider; class CurrencyServiceProvider extends ServiceProvider { public function register(): void { - $this->app->singleton(CurrencyExchangeService::class, function ($app) { + $this->app->singleton(ExchangeRateProviderInterface::class, function (Application $app) { + $primaryProvider = $this->resolveProvider( + $app, + config('currency.default') + ); + + $fallbackProvider = $this->resolveProvider( + $app, + config('currency.fallback') + ); + + $failoverProvider = new FailoverExchangeRateProvider( + $primaryProvider, + $fallbackProvider, + ); + + return new CachedExchangeRateProvider($failoverProvider); + }); + + $this->app->singleton(CurrencyExchangeService::class, function (Application $app) { return new CurrencyExchangeService( - $app->make(NbuApiCurrencyRepository::class), + $app->make(ExchangeRateProviderInterface::class), ); }); - $this->app->singleton(CurrencyExchangeFacade::class, function ($app) { + $this->app->singleton(CurrencyExchangeFacade::class, function (Application $app) { return new CurrencyExchangeFacade( $app->make(CurrencyExchangeService::class), ); }); } + + private function resolveProvider( + Application $app, + ?string $providerName, + ): ExchangeRateProviderInterface { + $providers = config('currency.providers', []); + $providerClass = $providers[$providerName] ?? null; + + if (! is_string($providerClass) || $providerClass === '') { + throw new \RuntimeException( + sprintf('Currency provider [%s] is not configured.', $providerName) + ); + } + + $provider = $app->make($providerClass); + + if (! $provider instanceof ExchangeRateProviderInterface) { + throw new \RuntimeException( + sprintf( + 'Currency provider [%s] must implement %s.', + $providerName, + ExchangeRateProviderInterface::class, + ) + ); + } + + return $provider; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ee65e59..23d75fa 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use Illuminate\Support\ServiceProvider; +use Modules\Currency\Providers\CurrencyServiceProvider; class AppServiceProvider extends ServiceProvider { @@ -11,7 +12,7 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - $this->app->register(\Modules\Currency\Providers\CurrencyServiceProvider::class); + $this->app->register(CurrencyServiceProvider::class); } /** diff --git a/app/Services/Catalog/SmartphoneCatalogClient.php b/app/Services/Catalog/SmartphoneCatalogClient.php new file mode 100644 index 0000000..ba8f5bd --- /dev/null +++ b/app/Services/Catalog/SmartphoneCatalogClient.php @@ -0,0 +1,27 @@ +timeout((int) config('services.dummyjson.timeout_seconds', 5)) + ->get('/products/category/smartphones', [ + 'limit' => $limit, + ]) + ->throw(); + + $products = $response->json('products'); + + if (! is_array($products)) { + throw new RuntimeException('Invalid smartphones catalog response.'); + } + + return $products; + } +} diff --git a/config/currency.php b/config/currency.php new file mode 100644 index 0000000..7462b07 --- /dev/null +++ b/config/currency.php @@ -0,0 +1,26 @@ + env('CURRENCY_PROVIDER', 'nbu'), + 'fallback' => env('CURRENCY_FALLBACK_PROVIDER', 'open_er_api'), + + 'providers' => [ + 'nbu' => \Modules\Currency\Infrastructure\Providers\NbuExchangeRateProvider::class, + 'open_er_api' => \Modules\Currency\Infrastructure\Providers\OpenErApiExchangeRateProvider::class, + ], + + 'api_urls' => [ + 'nbu' => env( + 'CURRENCY_NBU_API_URL', + 'https://bank.gov.ua/NBUStatService/v1/statdirectory/exchange' + ), + + 'open_er_api' => env( + 'CURRENCY_OPEN_ER_API_URL', + 'https://open.er-api.com/v6/latest/UAH' + ), + ], + + 'cache_ttl_minutes' => env('CURRENCY_CACHE_TTL_MINUTES', 10), + 'timeout_seconds' => env('CURRENCY_TIMEOUT_SECONDS', 5), +]; diff --git a/config/services.php b/config/services.php index 6a90eb8..2284cbc 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,8 @@ ], ], + 'dummyjson' => [ + 'base_url' => env('DUMMYJSON_BASE_URL', 'https://dummyjson.com'), + 'timeout_seconds' => env('DUMMYJSON_TIMEOUT_SECONDS', 5), + ], ]; diff --git a/database/migrations/2026_03_11_000000_create_catalog_currency_usages_table.php b/database/migrations/2026_03_11_000000_create_catalog_currency_usages_table.php new file mode 100644 index 0000000..199ccc7 --- /dev/null +++ b/database/migrations/2026_03_11_000000_create_catalog_currency_usages_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('currency'); + $table->timestamp('requested_at'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('catalog_currency_usages'); + } +}; + 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/CatalogEurTest.php b/tests/Feature/CatalogEurTest.php new file mode 100644 index 0000000..56a5ee1 --- /dev/null +++ b/tests/Feature/CatalogEurTest.php @@ -0,0 +1,54 @@ + Http::response([ + 'products' => [ + [ + 'id' => 1, + 'title' => 'iPhone 5s', + 'description' => 'A classic smartphone.', + 'category' => 'smartphones', + 'price' => 200.0, + 'rating' => 2.83, + 'brand' => 'Apple', + 'thumbnail' => 'https://cdn.dummyjson.com/product-images/smartphones/iphone-5s/thumbnail.webp', + ], + ], + 'total' => 1, + 'skip' => 0, + 'limit' => 5, + ], 200), + + 'https://bank.gov.ua/*' => Http::response([ + ['r030' => 840, 'txt' => 'Долар США', 'rate' => 40.0, 'cc' => 'USD', 'exchangedate' => '02.03.2026'], + ['r030' => 978, 'txt' => 'Євро', 'rate' => 50.0, 'cc' => 'EUR', 'exchangedate' => '02.03.2026'], + ], 200), + ]); + + $response = $this->getJson('/api/catalog/eur'); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', 1) + ->assertJsonPath('data.0.title', 'iPhone 5s'); + + $this->assertEquals(160.0, $response->json('data.0.price')); + + Queue::assertPushed(TrackCatalogCurrencyUsage::class, function (TrackCatalogCurrencyUsage $job) { + return $job->currency === 'EUR'; + }); + } +} diff --git a/tests/Feature/CatalogTest.php b/tests/Feature/CatalogTest.php index 80ba085..3f380cf 100644 --- a/tests/Feature/CatalogTest.php +++ b/tests/Feature/CatalogTest.php @@ -3,10 +3,18 @@ namespace Tests\Feature; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Queue; use Tests\TestCase; class CatalogTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + Queue::fake(); + } + private function fakeApis(float $usdRate = 43.0996): void { Http::fake([ @@ -77,8 +85,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,13 +99,6 @@ public function test_catalog_endpoint_does_not_require_authentication(): void ->assertJsonStructure(['data']); } - public function test_catalog_eur_returns_not_found(): void - { - $response = $this->getJson('/api/catalog/EUR'); - - $response->assertStatus(404); - } - public function test_catalog_rejects_unsupported_currency(): void { $response = $this->getJson('/api/catalog/GBP'); diff --git a/tests/Feature/CatalogUsageTest.php b/tests/Feature/CatalogUsageTest.php new file mode 100644 index 0000000..3e03608 --- /dev/null +++ b/tests/Feature/CatalogUsageTest.php @@ -0,0 +1,41 @@ + Http::response([ + 'products' => [ + [ + 'id' => 1, + 'title' => 'iPhone 13', + 'price' => 1000, + 'rating' => 4.5, + 'thumbnail' => 'https://example.com/iphone.jpg', + ], + ], + ], 200), + ]); + + $response = $this->getJson('/api/catalog/usd'); + + $response->assertOk(); + + Queue::assertPushed(TrackCatalogCurrencyUsage::class, function (TrackCatalogCurrencyUsage $job) { + return $job->currency === 'USD'; + }); + } +} diff --git a/tests/Feature/ExchangeRateEurTest.php b/tests/Feature/ExchangeRateEurTest.php new file mode 100644 index 0000000..52f545d --- /dev/null +++ b/tests/Feature/ExchangeRateEurTest.php @@ -0,0 +1,27 @@ + Http::response([ + ['r030' => 840, 'txt' => 'Долар США', 'rate' => 40.0, 'cc' => 'USD', 'exchangedate' => '02.03.2026'], + ['r030' => 978, 'txt' => 'Євро', 'rate' => 50.0, 'cc' => 'EUR', 'exchangedate' => '02.03.2026'], + ], 200), + ]); + + $response = $this->getJson('/api/exchangeRate/EUR'); + + $response->assertOk() + ->assertJsonPath('data.currencyCode', 'EUR') + ->assertJsonPath('data.exchangeDate', '02.03.2026'); + + $this->assertEquals(50.0, $response->json('data.rate')); + } +} diff --git a/tests/Feature/ExchangeRateTest.php b/tests/Feature/ExchangeRateTest.php index 12f238e..86ebd67 100644 --- a/tests/Feature/ExchangeRateTest.php +++ b/tests/Feature/ExchangeRateTest.php @@ -11,8 +11,8 @@ private function fakeNbuApi(float $usdRate = 43.0996): void { Http::fake([ 'bank.gov.ua/*' => Http::response([ - ['r030' => 840, 'txt' => 'Долар США', 'rate' => $usdRate, 'cc' => 'USD', 'exchangedate' => '02.03.2026'], - ['r030' => 978, 'txt' => 'Євро', 'rate' => 50.8661, 'cc' => 'EUR', 'exchangedate' => '02.03.2026'], + ['r030' => 840, 'txt' => 'USD', 'rate' => $usdRate, 'cc' => 'USD', 'exchangedate' => '02.03.2026'], + ['r030' => 978, 'txt' => 'EUR', 'rate' => 50.8661, 'cc' => 'EUR', 'exchangedate' => '02.03.2026'], ]), ]); } diff --git a/tests/Feature/TrackCatalogCurrencyUsageJobTest.php b/tests/Feature/TrackCatalogCurrencyUsageJobTest.php new file mode 100644 index 0000000..bdf3e18 --- /dev/null +++ b/tests/Feature/TrackCatalogCurrencyUsageJobTest.php @@ -0,0 +1,26 @@ +handle(); + + $this->assertDatabaseHas('catalog_currency_usages', [ + 'currency' => 'USD', + ]); + } +} diff --git a/tests/Unit/CachedExchangeRateProviderTest.php b/tests/Unit/CachedExchangeRateProviderTest.php new file mode 100644 index 0000000..de081ae --- /dev/null +++ b/tests/Unit/CachedExchangeRateProviderTest.php @@ -0,0 +1,133 @@ +calls++; + + return $this->calls === 1 ? [$this->first] : [$this->second]; + } + + public function getRate(string $currencyCode): CurrencyRate + { + return $this->getAll()[0]; + } + }; + + $provider = new CachedExchangeRateProvider($inner); + + $firstCall = $provider->getAll(); + $secondCall = $provider->getAll(); + + $this->assertCount(1, $firstCall); + $this->assertCount(1, $secondCall); + $this->assertSame(40.0, $firstCall[0]->rate); + $this->assertSame(40.0, $secondCall[0]->rate); + $this->assertSame(1, $inner->calls); + } + + public function test_new_day_uses_new_cache_key(): void + { + Cache::flush(); + + $rateDay1 = new CurrencyRate('USD', 40.0, 'USD', '01.01.2026'); + $rateDay2 = new CurrencyRate('USD', 41.0, 'USD', '02.01.2026'); + + $inner = new class($rateDay1, $rateDay2) implements ExchangeRateProviderInterface { + public int $calls = 0; + + public function __construct( + private CurrencyRate $first, + private CurrencyRate $second, + ) {} + + public function getAll(): array + { + $this->calls++; + + return $this->calls === 1 ? [$this->first] : [$this->second]; + } + + public function getRate(string $currencyCode): CurrencyRate + { + return $this->getAll()[0]; + } + }; + + $provider = new CachedExchangeRateProvider($inner); + + $this->travelTo('2026-01-01 10:00:00'); + $day1 = $provider->getAll(); + $this->assertSame(40.0, $day1[0]->rate); + + $this->travelTo('2026-01-02 10:00:00'); + $day2 = $provider->getAll(); + $this->assertSame(41.0, $day2[0]->rate); + + $this->assertSame(2, $inner->calls); + + $this->travelBack(); + } + + public function test_get_rate_uses_cached_rates(): void + { + Cache::flush(); + + $rate1 = new CurrencyRate('USD', 40.0, 'USD', '01.01.2026'); + $rate2 = new CurrencyRate('USD', 41.0, 'USD', '01.01.2026'); + + $inner = new class($rate1, $rate2) implements ExchangeRateProviderInterface { + public int $calls = 0; + + public function __construct( + private CurrencyRate $first, + private CurrencyRate $second, + ) {} + + public function getAll(): array + { + $this->calls++; + + return $this->calls === 1 ? [$this->first] : [$this->second]; + } + + public function getRate(string $currencyCode): CurrencyRate + { + return $this->getAll()[0]; + } + }; + + $provider = new CachedExchangeRateProvider($inner); + + $first = $provider->getRate('USD'); + $second = $provider->getRate('USD'); + + $this->assertSame(40.0, $first->rate); + $this->assertSame(40.0, $second->rate); + $this->assertSame(1, $inner->calls); + } +} diff --git a/tests/Unit/CurrencyExchangeServiceTest.php b/tests/Unit/CurrencyExchangeServiceTest.php new file mode 100644 index 0000000..7c44020 --- /dev/null +++ b/tests/Unit/CurrencyExchangeServiceTest.php @@ -0,0 +1,95 @@ +rates; + } + + public function getRate(string $currencyCode): CurrencyRate + { + foreach ($this->rates as $rate) { + if ($rate->currencyCode === $currencyCode) { + return $rate; + } + } + + throw new ExchangeRateProviderException('Rate not found.'); + } + }; + + return new CurrencyExchangeService($provider); + } + + public function test_convert_usd_to_usd(): void + { + $service = $this->createServiceWithRates([]); + + $converted = $service->convertUsdTo(CurrencyCode::USD, 100.0); + + $this->assertSame(100.0, $converted->originalPrice); + $this->assertSame(100.0, $converted->convertedPrice); + $this->assertSame('USD', $converted->fromCurrency); + $this->assertSame('USD', $converted->toCurrency); + $this->assertSame(1.0, $converted->rate); + } + + public function test_convert_usd_to_uah(): void + { + $usdRate = new CurrencyRate('USD', 40.0, 'USD', '01.01.2026'); + $eurRate = new CurrencyRate('EUR', 50.0, 'EUR', '01.01.2026'); + + $service = $this->createServiceWithRates([$usdRate, $eurRate]); + + $converted = $service->convertUsdTo(CurrencyCode::UAH, 100.0); + + $this->assertSame(100.0, $converted->originalPrice); + $this->assertEquals(4000.0, $converted->convertedPrice); + $this->assertSame('USD', $converted->fromCurrency); + $this->assertSame('UAH', $converted->toCurrency); + $this->assertEquals(40.0, $converted->rate); + } + + public function test_convert_usd_to_eur(): void + { + $usdRate = new CurrencyRate('USD', 40.0, 'USD', '01.01.2026'); + $eurRate = new CurrencyRate('EUR', 50.0, 'EUR', '01.01.2026'); + + $service = $this->createServiceWithRates([$usdRate, $eurRate]); + + $converted = $service->convertUsdTo(CurrencyCode::EUR, 100.0); + + $this->assertSame(100.0, $converted->originalPrice); + $this->assertEquals(80.0, $converted->convertedPrice); + $this->assertSame('USD', $converted->fromCurrency); + $this->assertSame('EUR', $converted->toCurrency); + $this->assertEquals(0.8, $converted->rate); + } + + public function test_convert_usd_to_throws_when_rate_missing(): void + { + $usdRate = new CurrencyRate('USD', 40.0, 'USD', '01.01.2026'); + + $service = $this->createServiceWithRates([$usdRate]); + + $this->expectException(ExchangeRateProviderException::class); + $this->expectExceptionMessage('Rate not found.'); + + $service->convertUsdTo(CurrencyCode::EUR, 100.0); + } +} diff --git a/tests/Unit/FailoverExchangeRateProviderTest.php b/tests/Unit/FailoverExchangeRateProviderTest.php new file mode 100644 index 0000000..29a512d --- /dev/null +++ b/tests/Unit/FailoverExchangeRateProviderTest.php @@ -0,0 +1,130 @@ +rate]; + } + + public function getRate(string $currencyCode): CurrencyRate + { + return $this->rate; + } + }; + + $fallback = new class($this) implements ExchangeRateProviderInterface { + public function __construct(private TestCase $testCase) {} + + public function getAll(): array + { + $this->testCase->fail('Fallback should not be called.'); + } + + public function getRate(string $currencyCode): CurrencyRate + { + $this->testCase->fail('Fallback should not be called.'); + } + }; + + $provider = new FailoverExchangeRateProvider($primary, $fallback); + + $all = $provider->getAll(); + $this->assertCount(1, $all); + $this->assertSame($rate, $all[0]); + + $single = $provider->getRate('USD'); + $this->assertSame($rate, $single); + } + + public function test_primary_provider_exception_uses_fallback(): void + { + $fallbackRate = new CurrencyRate('USD', 41.0, 'USD', '01.01.2026'); + + $primary = new class implements ExchangeRateProviderInterface { + public function getAll(): array + { + throw new ExchangeRateProviderException('Primary failed.'); + } + + public function getRate(string $currencyCode): CurrencyRate + { + throw new ExchangeRateProviderException('Primary failed.'); + } + }; + + $fallback = new class($fallbackRate) implements ExchangeRateProviderInterface { + public function __construct(private CurrencyRate $rate) {} + + public function getAll(): array + { + return [$this->rate]; + } + + public function getRate(string $currencyCode): CurrencyRate + { + return $this->rate; + } + }; + + $provider = new FailoverExchangeRateProvider($primary, $fallback); + + $all = $provider->getAll(); + $this->assertCount(1, $all); + $this->assertSame($fallbackRate, $all[0]); + + $single = $provider->getRate('USD'); + $this->assertSame($fallbackRate, $single); + } + + public function test_unexpected_exception_is_not_swallowed(): void + { + $primary = new class implements ExchangeRateProviderInterface { + public function getAll(): array + { + throw new \RuntimeException('Unexpected failure.'); + } + + public function getRate(string $currencyCode): CurrencyRate + { + throw new \RuntimeException('Unexpected failure.'); + } + }; + + $fallback = new class($this) implements ExchangeRateProviderInterface { + public function __construct(private TestCase $testCase) {} + + public function getAll(): array + { + $this->testCase->fail('Fallback should not be called for unexpected exceptions.'); + } + + public function getRate(string $currencyCode): CurrencyRate + { + $this->testCase->fail('Fallback should not be called for unexpected exceptions.'); + } + }; + + $provider = new FailoverExchangeRateProvider($primary, $fallback); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unexpected failure.'); + + $provider->getAll(); + } +}