diff --git a/src/currency/services/exchange-rate.service.spec.ts b/src/currency/services/exchange-rate.service.spec.ts new file mode 100644 index 00000000..49996e38 --- /dev/null +++ b/src/currency/services/exchange-rate.service.spec.ts @@ -0,0 +1,144 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { HttpService } from '@nestjs/axios'; +import { of, throwError } from 'rxjs'; +import { ExchangeRateService } from './exchange-rate.service'; +import { CachingService } from '../../caching/caching.service'; + +describe('ExchangeRateService', () => { + let service: ExchangeRateService; + let cachingService: { + get: jest.Mock; + set: jest.Mock; + getOrSet: jest.Mock; + delete: jest.Mock; + clear: jest.Mock; + }; + let httpService: { get: jest.Mock }; + + const mockCachingService = { + get: jest.fn(), + set: jest.fn().mockResolvedValue(undefined), + getOrSet: jest.fn(), + delete: jest.fn(), + clear: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn().mockReturnValue('https://api.example.com/v4/latest/USD'), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + httpService = { get: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ExchangeRateService, + { provide: ConfigService, useValue: mockConfigService }, + { provide: HttpService, useValue: httpService }, + { provide: CachingService, useValue: mockCachingService }, + ], + }).compile(); + + service = module.get(ExchangeRateService); + cachingService = module.get(CachingService) as typeof cachingService; + }); + + describe('getExchangeRate', () => { + it('returns 1 for same currency', async () => { + const result = await service.getExchangeRate('USD', 'usd'); + expect(result).toBe(1); + expect(cachingService.get).not.toHaveBeenCalled(); + }); + + it('returns cached value from fresh cache without calling API', async () => { + cachingService.get.mockResolvedValueOnce(0.92); + + const result = await service.getExchangeRate('USD', 'EUR'); + + expect(result).toBe(0.92); + expect(cachingService.get).toHaveBeenCalledWith('exchange-rate:USD:EUR'); + expect(httpService.get).not.toHaveBeenCalled(); + }); + + it('returns stale cache value and triggers background refresh', async () => { + cachingService.get.mockResolvedValueOnce(undefined).mockResolvedValueOnce(0.92); + httpService.get.mockReturnValue(of({ data: { rates: { EUR: 0.93 } } })); + + const result = await service.getExchangeRate('USD', 'EUR'); + + expect(result).toBe(0.92); + expect(cachingService.get).toHaveBeenNthCalledWith(1, 'exchange-rate:USD:EUR'); + expect(cachingService.get).toHaveBeenNthCalledWith(2, 'exchange-rate:USD:EUR:stale'); + + await new Promise(process.nextTick); + expect(httpService.get).toHaveBeenCalledTimes(1); + expect(cachingService.set).toHaveBeenCalledWith('exchange-rate:USD:EUR', 0.93, 3600); + expect(cachingService.set).toHaveBeenCalledWith('exchange-rate:USD:EUR:stale', 0.93, 7200); + }); + + it('fetches from API on complete miss and caches result', async () => { + cachingService.get.mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined); + cachingService.getOrSet.mockImplementationOnce( + async (_key: string, factory: () => Promise) => factory(), + ); + httpService.get.mockReturnValue(of({ data: { rates: { EUR: 0.92 } } })); + + const result = await service.getExchangeRate('USD', 'EUR'); + + expect(result).toBe(0.92); + expect(cachingService.getOrSet).toHaveBeenCalledWith( + 'exchange-rate:USD:EUR', + expect.any(Function), + 3600, + ); + expect(cachingService.set).toHaveBeenCalledWith('exchange-rate:USD:EUR:stale', 0.92, 7200); + }); + + it('falls back to stale cache when API fails on complete miss', async () => { + cachingService.get.mockResolvedValueOnce(undefined).mockResolvedValueOnce(0.85); + cachingService.getOrSet.mockRejectedValueOnce(new Error('API error')); + + const result = await service.getExchangeRate('GBP', 'EUR'); + + expect(result).toBe(0.85); + }); + + it('falls back to hardcoded rates when all caches miss and API fails', async () => { + cachingService.get.mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined); + cachingService.getOrSet.mockRejectedValueOnce(new Error('API error')); + + const result = await service.getExchangeRate('USD', 'EUR'); + + expect(result).toBe(0.92); + }); + + it('calculates cross-currency rate correctly for non-base currencies', async () => { + cachingService.get.mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined); + cachingService.getOrSet.mockImplementationOnce( + async (_key: string, factory: () => Promise) => factory(), + ); + httpService.get.mockReturnValue(of({ data: { rates: { GBP: 0.79, EUR: 0.92 } } })); + + const result = await service.getExchangeRate('GBP', 'EUR'); + + expect(result).toBeCloseTo(1.1646, 3); + }); + }); + + describe('getAvailableRates', () => { + it('returns snapshot of fallback rates', () => { + const rates = service.getAvailableRates(); + expect(rates).toHaveProperty('EUR', 0.92); + expect(rates).toHaveProperty('GBP', 0.79); + }); + }); + + describe('refreshExchangeRates', () => { + it('logs and returns without error', async () => { + await expect(service.refreshExchangeRates()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/src/currency/services/exchange-rate.service.ts b/src/currency/services/exchange-rate.service.ts index 5be99483..ee729ce8 100644 --- a/src/currency/services/exchange-rate.service.ts +++ b/src/currency/services/exchange-rate.service.ts @@ -1,25 +1,20 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; import { firstValueFrom } from 'rxjs'; +import { CachingService } from '../../caching/caching.service'; interface ExchangeRates { [currency: string]: number; } -/** - * Exchange Rate Service - * Fetches and caches exchange rates for currency conversion - */ @Injectable() -export class ExchangeRateService implements OnModuleInit { +export class ExchangeRateService { private readonly logger = new Logger(ExchangeRateService.name); - private exchangeRates: ExchangeRates = {}; - private lastUpdated: Date = new Date(0); - private readonly updateIntervalMs = 24 * 60 * 60 * 1000; // 24 hours private readonly baseCurrency = 'USD'; + private readonly cacheTtlSeconds = 3600; + private readonly staleTtlSeconds = 7200; - // Fallback exchange rates if API is unavailable (as of 2026) private readonly fallbackRates: ExchangeRates = { EUR: 0.92, GBP: 0.79, @@ -45,18 +40,9 @@ export class ExchangeRateService implements OnModuleInit { constructor( private readonly configService: ConfigService, private readonly httpService: HttpService, + private readonly cachingService: CachingService, ) {} - async onModuleInit(): Promise { - await this.refreshExchangeRates(); - } - - /** - * Get exchange rate between two currencies - * @param fromCurrency Source currency code - * @param toCurrency Target currency code - * @returns Exchange rate - */ async getExchangeRate(fromCurrency: string, toCurrency: string): Promise { const from = fromCurrency.toUpperCase(); const to = toCurrency.toUpperCase(); @@ -65,14 +51,50 @@ export class ExchangeRateService implements OnModuleInit { return 1; } - // Refresh rates if needed - if (this.shouldRefreshRates()) { - await this.refreshExchangeRates(); + const cacheKey = `exchange-rate:${from}:${to}`; + const staleKey = `exchange-rate:${from}:${to}:stale`; + + const fresh = await this.cachingService.get(cacheKey); + if (fresh !== undefined) { + return fresh; + } + + const stale = await this.cachingService.get(staleKey); + if (stale !== undefined) { + this.refreshInBackground(from, to, cacheKey, staleKey); + return stale; + } + + try { + return await this.cachingService.getOrSet( + cacheKey, + async () => { + const rate = await this.fetchRateFromApi(from, to); + await this.cachingService.set(staleKey, rate, this.staleTtlSeconds); + return rate; + }, + this.cacheTtlSeconds, + ); + } catch { + return this.computeFromFallback(from, to); + } + } + + private async fetchRateFromApi(from: string, to: string): Promise { + const apiUrl = this.configService.get( + 'EXCHANGE_RATE_API_URL', + 'https://api.exchangerate-api.com/v4/latest/USD', + ); + + const response = await firstValueFrom(this.httpService.get(apiUrl, { timeout: 5000 })); + + if (!response.data?.rates) { + throw new Error('Invalid API response: missing rates'); } - // If either currency is not base, calculate indirect rate - const fromRate = this.exchangeRates[from] || 1; - const toRate = this.exchangeRates[to] || 1; + const rates: ExchangeRates = response.data.rates; + const fromRate = rates[from] || 1; + const toRate = rates[to] || 1; if (from === this.baseCurrency) { return toRate; @@ -85,69 +107,47 @@ export class ExchangeRateService implements OnModuleInit { return toRate / fromRate; } - /** - * Refresh exchange rates from external API - */ - async refreshExchangeRates(): Promise { + private async refreshInBackground( + from: string, + to: string, + cacheKey: string, + staleKey: string, + ): Promise { try { - const _apiKey = this.configService.get( - 'EXCHANGE_RATE_API_KEY', - 'fixer', // Default to fixer.io free tier - ); - const apiUrl = this.configService.get( - 'EXCHANGE_RATE_API_URL', - 'https://api.exchangerate-api.com/v4/latest/USD', - ); - - try { - const response = await firstValueFrom( - this.httpService.get(apiUrl, { - timeout: 5000, - }), - ); - - if (response.data?.rates) { - this.exchangeRates = response.data.rates; - this.lastUpdated = new Date(); - this.logger.log('Exchange rates updated successfully'); - } - } catch (apiError) { - this.logger.warn('Failed to fetch exchange rates from API, using fallback rates', apiError); - this.exchangeRates = this.fallbackRates; - this.lastUpdated = new Date(); - } + const rate = await this.fetchRateFromApi(from, to); + await Promise.all([ + this.cachingService.set(cacheKey, rate, this.cacheTtlSeconds), + this.cachingService.set(staleKey, rate, this.staleTtlSeconds), + ]); } catch (error) { - this.logger.error('Error refreshing exchange rates', error); - if (Object.keys(this.exchangeRates).length === 0) { - this.exchangeRates = this.fallbackRates; - } + this.logger.warn(`Background refresh failed for ${from}:${to}`, error); } } - /** - * Check if exchange rates should be refreshed - */ - private shouldRefreshRates(): boolean { - const now = new Date(); - return now.getTime() - this.lastUpdated.getTime() > this.updateIntervalMs; + private computeFromFallback(from: string, to: string): number { + const fromRate = this.fallbackRates[from] || 1; + const toRate = this.fallbackRates[to] || 1; + + if (from === this.baseCurrency) { + return toRate; + } + + if (to === this.baseCurrency) { + return 1 / fromRate; + } + + return toRate / fromRate; } - /** - * Get all available exchange rates - * @returns Object with all exchange rates - */ getAvailableRates(): ExchangeRates { - return { ...this.exchangeRates }; + return { ...this.fallbackRates }; + } + + async refreshExchangeRates(): Promise { + this.logger.log('Exchange rate caches will refresh on next request via stale-while-revalidate'); } - /** - * Get exchange rates as of a specific date (not implemented in free tier) - * @param date The date for historical rates - * @returns Exchange rates for that date - */ async getHistoricalRates(_date: Date): Promise { - // For production, integrate with a service that supports historical rates - // For now, return current rates return this.getAvailableRates(); } }