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