currency-converter 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +7 -0
- data/.rubocop.yml +18 -0
- data/CHANGELOG.md +66 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +9 -1
- data/README.md +154 -40
- data/currency-converter.gemspec +5 -2
- data/lib/currency_converter/api_client.rb +86 -6
- data/lib/currency_converter/cache.rb +3 -2
- data/lib/currency_converter/config.rb +3 -2
- data/lib/currency_converter/converter.rb +44 -4
- data/lib/currency_converter/errors.rb +9 -0
- data/lib/currency_converter/version.rb +1 -1
- data/manual_integration_test.rb +157 -0
- data/spec/currency_converter/api_client_spec.rb +161 -10
- data/spec/currency_converter/cache_spec.rb +49 -11
- data/spec/currency_converter/converter_spec.rb +129 -16
- data/spec/currency_converter/version_spec.rb +15 -0
- data/spec/spec_helper.rb +7 -0
- metadata +8 -2
|
@@ -14,10 +14,11 @@ module CurrencyConverter
|
|
|
14
14
|
# The Converter class performs currency conversions using exchange rates.
|
|
15
15
|
# It retrieves rates from an external API and caches them for performance.
|
|
16
16
|
class Converter
|
|
17
|
-
def initialize(api_key: CurrencyConverter.configuration.api_key
|
|
18
|
-
|
|
17
|
+
def initialize(api_key: CurrencyConverter.configuration.api_key,
|
|
18
|
+
timeout: CurrencyConverter.configuration.timeout)
|
|
19
|
+
# Initializes the API client with the provided or configured API key and timeout.
|
|
19
20
|
# Initializes a caching object for exchange rates.
|
|
20
|
-
@api_client = APIClient.new(api_key)
|
|
21
|
+
@api_client = APIClient.new(api_key, timeout: timeout)
|
|
21
22
|
@cache = Cache.new
|
|
22
23
|
end
|
|
23
24
|
|
|
@@ -26,13 +27,21 @@ module CurrencyConverter
|
|
|
26
27
|
# @param from_currency [String] the source currency code (e.g., "USD")
|
|
27
28
|
# @param to_currency [String] the target currency code (e.g., "EUR")
|
|
28
29
|
# @return [Float] the converted amount
|
|
30
|
+
# @raise [CurrencyConverter::InvalidAmountError] if amount is invalid
|
|
31
|
+
# @raise [CurrencyConverter::InvalidCurrencyError] if currency code is invalid
|
|
29
32
|
# @raise [CurrencyConverter::APIError] if conversion fails due to API or network errors
|
|
30
33
|
def convert(amount, from_currency, to_currency)
|
|
34
|
+
validate_amount(amount)
|
|
35
|
+
validate_currency_code(from_currency, "from_currency")
|
|
36
|
+
validate_currency_code(to_currency, "to_currency")
|
|
37
|
+
|
|
31
38
|
rate = fetch_exchange_rate(from_currency, to_currency)
|
|
32
39
|
(amount * rate).round(2)
|
|
33
40
|
rescue CurrencyConverter::RateNotFoundError => e
|
|
34
41
|
log_error("Conversion failed: #{e.message}")
|
|
35
42
|
raise CurrencyConverter::APIError, "Conversion failed: #{e.message}"
|
|
43
|
+
rescue CurrencyConverter::InvalidAmountError, CurrencyConverter::InvalidCurrencyError
|
|
44
|
+
raise # Re-raise validation errors as-is
|
|
36
45
|
rescue StandardError => e
|
|
37
46
|
log_error("Conversion failed: #{e.message}")
|
|
38
47
|
raise
|
|
@@ -46,7 +55,8 @@ module CurrencyConverter
|
|
|
46
55
|
# @param to_currency [String] the target currency code
|
|
47
56
|
# @return [Float] the exchange rate
|
|
48
57
|
def fetch_exchange_rate(from_currency, to_currency)
|
|
49
|
-
|
|
58
|
+
cache_duration = CurrencyConverter.configuration.cache_duration
|
|
59
|
+
@cache.fetch("#{from_currency}_#{to_currency}", expires_in: cache_duration) do
|
|
50
60
|
@api_client.get_rate(from_currency, to_currency)
|
|
51
61
|
end
|
|
52
62
|
end
|
|
@@ -56,5 +66,35 @@ module CurrencyConverter
|
|
|
56
66
|
def log_error(message)
|
|
57
67
|
CurrencyConverter.configuration.logger.error(message)
|
|
58
68
|
end
|
|
69
|
+
|
|
70
|
+
# Validates the amount parameter.
|
|
71
|
+
# @param amount [Numeric] the amount to validate
|
|
72
|
+
# @raise [InvalidAmountError] if amount is invalid
|
|
73
|
+
def validate_amount(amount)
|
|
74
|
+
if amount.nil?
|
|
75
|
+
raise InvalidAmountError, "Amount cannot be nil"
|
|
76
|
+
elsif !amount.is_a?(Numeric)
|
|
77
|
+
raise InvalidAmountError, "Amount must be a number, got #{amount.class}"
|
|
78
|
+
elsif amount.negative?
|
|
79
|
+
raise InvalidAmountError, "Amount cannot be negative (got #{amount})"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Validates the currency code parameter.
|
|
84
|
+
# @param currency_code [String] the currency code to validate
|
|
85
|
+
# @param param_name [String] the parameter name for error messages
|
|
86
|
+
# @raise [InvalidCurrencyError] if currency code is invalid
|
|
87
|
+
def validate_currency_code(currency_code, param_name)
|
|
88
|
+
if currency_code.nil?
|
|
89
|
+
raise InvalidCurrencyError, "#{param_name} cannot be nil"
|
|
90
|
+
elsif !currency_code.is_a?(String)
|
|
91
|
+
raise InvalidCurrencyError, "#{param_name} must be a string, got #{currency_code.class}"
|
|
92
|
+
elsif currency_code.empty?
|
|
93
|
+
raise InvalidCurrencyError, "#{param_name} cannot be empty"
|
|
94
|
+
elsif !currency_code.match?(/\A[A-Z]{3}\z/)
|
|
95
|
+
raise InvalidCurrencyError,
|
|
96
|
+
"#{param_name} must be a 3-letter uppercase code (e.g., 'USD'), got '#{currency_code}'"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
59
99
|
end
|
|
60
100
|
end
|
|
@@ -6,4 +6,13 @@ module CurrencyConverter
|
|
|
6
6
|
|
|
7
7
|
# Custom error class for cases where a specific exchange rate is not found.
|
|
8
8
|
class RateNotFoundError < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Custom error class for invalid amount inputs.
|
|
11
|
+
class InvalidAmountError < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Custom error class for invalid currency code inputs.
|
|
14
|
+
class InvalidCurrencyError < StandardError; end
|
|
15
|
+
|
|
16
|
+
# Custom error class for timeout errors.
|
|
17
|
+
class TimeoutError < StandardError; end
|
|
9
18
|
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Manual Integration Test for CurrencyConverter v1.2.0
|
|
5
|
+
# This script tests the gem with real API calls
|
|
6
|
+
|
|
7
|
+
$LOAD_PATH.unshift File.expand_path("lib", __dir__)
|
|
8
|
+
require "currency_converter"
|
|
9
|
+
|
|
10
|
+
puts "=" * 80
|
|
11
|
+
puts "CurrencyConverter v1.2.0 - Manual Integration Test"
|
|
12
|
+
puts "=" * 80
|
|
13
|
+
puts
|
|
14
|
+
|
|
15
|
+
# Test 1: Open Access Mode (without API key)
|
|
16
|
+
puts "TEST 1: Open Access Mode (no API key)"
|
|
17
|
+
puts "-" * 80
|
|
18
|
+
CurrencyConverter.configure do |config|
|
|
19
|
+
config.api_key = nil
|
|
20
|
+
config.cache_duration = 60
|
|
21
|
+
config.timeout = 10
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
converter = CurrencyConverter::Converter.new
|
|
25
|
+
begin
|
|
26
|
+
result = converter.convert(100, "USD", "EUR")
|
|
27
|
+
puts "✅ Convert 100 USD to EUR: #{result} EUR"
|
|
28
|
+
puts " API Mode: v6 Open Access"
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
puts "❌ FAILED: #{e.class} - #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
puts
|
|
33
|
+
|
|
34
|
+
# Test 2: Cache functionality
|
|
35
|
+
puts "TEST 2: Cache Hit (should use cached value)"
|
|
36
|
+
puts "-" * 80
|
|
37
|
+
begin
|
|
38
|
+
result2 = converter.convert(100, "USD", "EUR")
|
|
39
|
+
puts "✅ Second conversion (cached): #{result2} EUR"
|
|
40
|
+
puts " Should be instant (from cache)"
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
puts "❌ FAILED: #{e.class} - #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
puts
|
|
45
|
+
|
|
46
|
+
# Test 3: Different currency pair
|
|
47
|
+
puts "TEST 3: Different Currency Pair"
|
|
48
|
+
puts "-" * 80
|
|
49
|
+
begin
|
|
50
|
+
result = converter.convert(100, "USD", "GBP")
|
|
51
|
+
puts "✅ Convert 100 USD to GBP: #{result} GBP"
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
puts "❌ FAILED: #{e.class} - #{e.message}"
|
|
54
|
+
end
|
|
55
|
+
puts
|
|
56
|
+
|
|
57
|
+
# Test 4: Input Validation - Negative Amount
|
|
58
|
+
puts "TEST 4: Input Validation - Negative Amount"
|
|
59
|
+
puts "-" * 80
|
|
60
|
+
begin
|
|
61
|
+
converter.convert(-100, "USD", "EUR")
|
|
62
|
+
puts "❌ FAILED: Should have raised InvalidAmountError"
|
|
63
|
+
rescue CurrencyConverter::InvalidAmountError => e
|
|
64
|
+
puts "✅ Correctly raised InvalidAmountError: #{e.message}"
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
puts "❌ FAILED: Wrong error type - #{e.class}"
|
|
67
|
+
end
|
|
68
|
+
puts
|
|
69
|
+
|
|
70
|
+
# Test 5: Input Validation - Invalid Currency Code
|
|
71
|
+
puts "TEST 5: Input Validation - Invalid Currency Code"
|
|
72
|
+
puts "-" * 80
|
|
73
|
+
begin
|
|
74
|
+
converter.convert(100, "usd", "EUR")
|
|
75
|
+
puts "❌ FAILED: Should have raised InvalidCurrencyError"
|
|
76
|
+
rescue CurrencyConverter::InvalidCurrencyError => e
|
|
77
|
+
puts "✅ Correctly raised InvalidCurrencyError: #{e.message}"
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
puts "❌ FAILED: Wrong error type - #{e.class}"
|
|
80
|
+
end
|
|
81
|
+
puts
|
|
82
|
+
|
|
83
|
+
# Test 6: Input Validation - Nil Amount
|
|
84
|
+
puts "TEST 6: Input Validation - Nil Amount"
|
|
85
|
+
puts "-" * 80
|
|
86
|
+
begin
|
|
87
|
+
converter.convert(nil, "USD", "EUR")
|
|
88
|
+
puts "❌ FAILED: Should have raised InvalidAmountError"
|
|
89
|
+
rescue CurrencyConverter::InvalidAmountError => e
|
|
90
|
+
puts "✅ Correctly raised InvalidAmountError: #{e.message}"
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
puts "❌ FAILED: Wrong error type - #{e.class}"
|
|
93
|
+
end
|
|
94
|
+
puts
|
|
95
|
+
|
|
96
|
+
# Test 7: Zero amount (edge case, should work)
|
|
97
|
+
puts "TEST 7: Zero Amount (should work)"
|
|
98
|
+
puts "-" * 80
|
|
99
|
+
begin
|
|
100
|
+
result = converter.convert(0, "USD", "EUR")
|
|
101
|
+
puts "✅ Convert 0 USD to EUR: #{result} EUR"
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
puts "❌ FAILED: #{e.class} - #{e.message}"
|
|
104
|
+
end
|
|
105
|
+
puts
|
|
106
|
+
|
|
107
|
+
# Test 8: Decimal amounts
|
|
108
|
+
puts "TEST 8: Decimal Amounts"
|
|
109
|
+
puts "-" * 80
|
|
110
|
+
begin
|
|
111
|
+
result = converter.convert(99.99, "USD", "EUR")
|
|
112
|
+
puts "✅ Convert 99.99 USD to EUR: #{result} EUR"
|
|
113
|
+
rescue StandardError => e
|
|
114
|
+
puts "❌ FAILED: #{e.class} - #{e.message}"
|
|
115
|
+
end
|
|
116
|
+
puts
|
|
117
|
+
|
|
118
|
+
# Test 9: Large numbers
|
|
119
|
+
puts "TEST 9: Large Numbers"
|
|
120
|
+
puts "-" * 80
|
|
121
|
+
begin
|
|
122
|
+
result = converter.convert(1_000_000, "USD", "EUR")
|
|
123
|
+
puts "✅ Convert 1,000,000 USD to EUR: #{result} EUR"
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
puts "❌ FAILED: #{e.class} - #{e.message}"
|
|
126
|
+
end
|
|
127
|
+
puts
|
|
128
|
+
|
|
129
|
+
# Test 10: Performance - Multiple Conversions
|
|
130
|
+
puts "TEST 10: Performance Test (10 conversions)"
|
|
131
|
+
puts "-" * 80
|
|
132
|
+
start_time = Time.now
|
|
133
|
+
successes = 0
|
|
134
|
+
begin
|
|
135
|
+
10.times do
|
|
136
|
+
converter.convert(100, "USD", "EUR")
|
|
137
|
+
successes += 1
|
|
138
|
+
end
|
|
139
|
+
elapsed = Time.now - start_time
|
|
140
|
+
puts "✅ Completed 10 conversions in #{elapsed.round(3)} seconds"
|
|
141
|
+
puts " Average: #{(elapsed / 10 * 1000).round(2)}ms per conversion"
|
|
142
|
+
puts " (Most should be cached, so very fast)"
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
puts "❌ FAILED after #{successes} conversions: #{e.class} - #{e.message}"
|
|
145
|
+
end
|
|
146
|
+
puts
|
|
147
|
+
|
|
148
|
+
puts "=" * 80
|
|
149
|
+
puts "Integration Test Complete!"
|
|
150
|
+
puts "=" * 80
|
|
151
|
+
puts
|
|
152
|
+
puts "Note: These tests use real API calls. If they fail, it might be due to:"
|
|
153
|
+
puts " - Network connectivity issues"
|
|
154
|
+
puts " - API rate limiting"
|
|
155
|
+
puts " - API service downtime"
|
|
156
|
+
puts
|
|
157
|
+
puts "For full test coverage, run: bundle exec rake spec"
|
|
@@ -4,21 +4,172 @@ require "currency_converter"
|
|
|
4
4
|
require "webmock/rspec"
|
|
5
5
|
|
|
6
6
|
RSpec.describe CurrencyConverter::APIClient do
|
|
7
|
-
let(:client) { described_class.new("test_key") }
|
|
8
|
-
|
|
9
7
|
describe "#get_rate" do
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
context "with authenticated v6 API (with API key)" do
|
|
9
|
+
let(:client) { described_class.new("test_api_key_123", timeout: 5) }
|
|
10
|
+
|
|
11
|
+
it "logs authenticated API mode on initialization" do
|
|
12
|
+
logger = instance_double(Logger, info: nil)
|
|
13
|
+
allow(CurrencyConverter).to receive(:configuration).and_return(
|
|
14
|
+
double(logger: logger, timeout: 10)
|
|
15
|
+
)
|
|
16
|
+
expect(logger).to receive(:info).with(/Using v6 authenticated API/)
|
|
17
|
+
|
|
18
|
+
described_class.new("test_key")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "handles nil logger gracefully" do
|
|
22
|
+
allow(CurrencyConverter).to receive(:configuration).and_return(
|
|
23
|
+
double(logger: nil, timeout: 10)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
expect { described_class.new("test_key") }.not_to raise_error
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "returns the exchange rate for a valid currency pair" do
|
|
30
|
+
stub_request(:get, "https://v6.exchangerate-api.com/v6/test_api_key_123/latest/USD")
|
|
31
|
+
.to_return(body: { result: "success", conversion_rates: { "EUR" => 0.85 } }.to_json)
|
|
32
|
+
|
|
33
|
+
expect(client.get_rate("USD", "EUR")).to eq(0.85)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "uses custom timeout configuration" do
|
|
37
|
+
custom_client = described_class.new("test_api_key_123", timeout: 15)
|
|
38
|
+
stub_request(:get, "https://v6.exchangerate-api.com/v6/test_api_key_123/latest/USD")
|
|
39
|
+
.to_return(body: { result: "success", conversion_rates: { "EUR" => 0.85 } }.to_json)
|
|
40
|
+
|
|
41
|
+
expect(custom_client.get_rate("USD", "EUR")).to eq(0.85)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "raises APIError for invalid API key" do
|
|
45
|
+
stub_request(:get, "https://v6.exchangerate-api.com/v6/test_api_key_123/latest/USD")
|
|
46
|
+
.to_return(body: { result: "error", "error-type" => "invalid-key" }.to_json)
|
|
47
|
+
|
|
48
|
+
expect { client.get_rate("USD", "EUR") }
|
|
49
|
+
.to raise_error(CurrencyConverter::APIError, /Invalid API key/)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "raises APIError for quota reached" do
|
|
53
|
+
stub_request(:get, "https://v6.exchangerate-api.com/v6/test_api_key_123/latest/USD")
|
|
54
|
+
.to_return(body: { result: "error", "error-type" => "quota-reached" }.to_json)
|
|
55
|
+
|
|
56
|
+
expect { client.get_rate("USD", "EUR") }
|
|
57
|
+
.to raise_error(CurrencyConverter::APIError, /rate limit quota reached/)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "raises APIError for inactive account" do
|
|
61
|
+
stub_request(:get, "https://v6.exchangerate-api.com/v6/test_api_key_123/latest/USD")
|
|
62
|
+
.to_return(body: { result: "error", "error-type" => "inactive-account" }.to_json)
|
|
63
|
+
|
|
64
|
+
expect { client.get_rate("USD", "EUR") }
|
|
65
|
+
.to raise_error(CurrencyConverter::APIError, /account is inactive/)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
context "with open access v6 API (without API key)" do
|
|
70
|
+
let(:client) { described_class.new(nil, timeout: 5) }
|
|
71
|
+
|
|
72
|
+
it "logs open access API mode on initialization" do
|
|
73
|
+
logger = instance_double(Logger, info: nil)
|
|
74
|
+
allow(CurrencyConverter).to receive(:configuration).and_return(
|
|
75
|
+
double(logger: logger, timeout: 10)
|
|
76
|
+
)
|
|
77
|
+
expect(logger).to receive(:info).with(/Using v6 open access API/)
|
|
78
|
+
|
|
79
|
+
described_class.new(nil)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "returns the exchange rate using open access endpoint" do
|
|
83
|
+
stub_request(:get, "https://open.er-api.com/v6/latest/USD")
|
|
84
|
+
.to_return(body: { result: "success", rates: { "EUR" => 0.85 } }.to_json)
|
|
85
|
+
|
|
86
|
+
expect(client.get_rate("USD", "EUR")).to eq(0.85)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it "works with empty string API key" do
|
|
90
|
+
empty_key_client = described_class.new("", timeout: 5)
|
|
91
|
+
stub_request(:get, "https://open.er-api.com/v6/latest/USD")
|
|
92
|
+
.to_return(body: { result: "success", rates: { "GBP" => 0.76 } }.to_json)
|
|
93
|
+
|
|
94
|
+
expect(empty_key_client.get_rate("USD", "GBP")).to eq(0.76)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "supports conversion_rates field (v6 format)" do
|
|
98
|
+
stub_request(:get, "https://open.er-api.com/v6/latest/USD")
|
|
99
|
+
.to_return(body: { result: "success", conversion_rates: { "JPY" => 156.84 } }.to_json)
|
|
100
|
+
|
|
101
|
+
expect(client.get_rate("USD", "JPY")).to eq(156.84)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
context "with error scenarios" do
|
|
106
|
+
let(:client) { described_class.new("test_key", timeout: 5) }
|
|
107
|
+
|
|
108
|
+
it "raises a RateNotFoundError if the rate is missing" do
|
|
109
|
+
stub_request(:get, "https://v6.exchangerate-api.com/v6/test_key/latest/USD")
|
|
110
|
+
.to_return(body: { result: "success", conversion_rates: {} }.to_json)
|
|
111
|
+
|
|
112
|
+
expect { client.get_rate("USD", "EUR") }
|
|
113
|
+
.to raise_error(CurrencyConverter::RateNotFoundError, /EUR rate not available/)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "raises an APIError for invalid JSON response" do
|
|
117
|
+
stub_request(:get, "https://v6.exchangerate-api.com/v6/test_key/latest/USD")
|
|
118
|
+
.to_return(body: "invalid json")
|
|
119
|
+
|
|
120
|
+
expect { client.get_rate("USD", "EUR") }
|
|
121
|
+
.to raise_error(CurrencyConverter::APIError, /Invalid API response format/)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "raises a TimeoutError when request times out" do
|
|
125
|
+
stub_request(:get, "https://v6.exchangerate-api.com/v6/test_key/latest/USD")
|
|
126
|
+
.to_timeout
|
|
127
|
+
|
|
128
|
+
expect { client.get_rate("USD", "EUR") }
|
|
129
|
+
.to raise_error(CurrencyConverter::TimeoutError, /Request timed out/)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it "raises an APIError for network errors" do
|
|
133
|
+
stub_request(:get, "https://v6.exchangerate-api.com/v6/test_key/latest/USD")
|
|
134
|
+
.to_raise(SocketError.new("Failed to open TCP connection"))
|
|
135
|
+
|
|
136
|
+
expect { client.get_rate("USD", "EUR") }
|
|
137
|
+
.to raise_error(CurrencyConverter::APIError, /Network error/)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it "raises an APIError for unsupported currency code" do
|
|
141
|
+
stub_request(:get, "https://v6.exchangerate-api.com/v6/test_key/latest/USD")
|
|
142
|
+
.to_return(body: { result: "error", "error-type" => "unsupported-code" }.to_json)
|
|
143
|
+
|
|
144
|
+
expect { client.get_rate("USD", "XYZ") }
|
|
145
|
+
.to raise_error(CurrencyConverter::APIError, /Unsupported currency code/)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it "raises an APIError for unknown error types" do
|
|
149
|
+
stub_request(:get, "https://v6.exchangerate-api.com/v6/test_key/latest/USD")
|
|
150
|
+
.to_return(body: { result: "error", "error-type" => "some-unknown-error" }.to_json)
|
|
151
|
+
|
|
152
|
+
expect { client.get_rate("USD", "EUR") }
|
|
153
|
+
.to raise_error(CurrencyConverter::APIError, /API error: some-unknown-error/)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it "raises an APIError when error type is nil" do
|
|
157
|
+
stub_request(:get, "https://v6.exchangerate-api.com/v6/test_key/latest/USD")
|
|
158
|
+
.to_return(body: { result: "error" }.to_json)
|
|
13
159
|
|
|
14
|
-
|
|
160
|
+
expect { client.get_rate("USD", "EUR") }
|
|
161
|
+
.to raise_error(CurrencyConverter::APIError, /API error: unknown error/)
|
|
162
|
+
end
|
|
15
163
|
end
|
|
16
164
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
165
|
+
context "with default timeout" do
|
|
166
|
+
it "uses default timeout when not specified" do
|
|
167
|
+
default_client = described_class.new("test_key")
|
|
168
|
+
stub_request(:get, "https://v6.exchangerate-api.com/v6/test_key/latest/USD")
|
|
169
|
+
.to_return(body: { result: "success", conversion_rates: { "GBP" => 0.76 } }.to_json)
|
|
20
170
|
|
|
21
|
-
|
|
171
|
+
expect(default_client.get_rate("USD", "GBP")).to eq(0.76)
|
|
172
|
+
end
|
|
22
173
|
end
|
|
23
174
|
end
|
|
24
175
|
end
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# frozen_string_literal: true
|
|
4
|
-
|
|
5
3
|
require "currency_converter"
|
|
6
4
|
|
|
7
5
|
RSpec.describe CurrencyConverter::Cache do
|
|
@@ -13,15 +11,55 @@ RSpec.describe CurrencyConverter::Cache do
|
|
|
13
11
|
|
|
14
12
|
let(:cache) { described_class.new }
|
|
15
13
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
describe "#fetch" do
|
|
15
|
+
it "caches values and returns cached value on subsequent calls" do
|
|
16
|
+
result = cache.fetch("test_key", expires_in: 60) { "original_value" }
|
|
17
|
+
expect(result).to eq("original_value")
|
|
18
|
+
|
|
19
|
+
# Second fetch should return cached value
|
|
20
|
+
cached_result = cache.fetch("test_key", expires_in: 60) { "new_value" }
|
|
21
|
+
expect(cached_result).to eq("original_value")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "respects the expires_in parameter" do
|
|
25
|
+
# Cache with 1 second expiration
|
|
26
|
+
result = cache.fetch("expiring_key", expires_in: 1) { "first_value" }
|
|
27
|
+
expect(result).to eq("first_value")
|
|
28
|
+
|
|
29
|
+
# Immediately fetching should return cached value
|
|
30
|
+
cached_result = cache.fetch("expiring_key", expires_in: 1) { "second_value" }
|
|
31
|
+
expect(cached_result).to eq("first_value")
|
|
32
|
+
|
|
33
|
+
# After expiration, should return new value
|
|
34
|
+
sleep 1.1 # Wait for expiration
|
|
35
|
+
expired_result = cache.fetch("expiring_key", expires_in: 1) { "third_value" }
|
|
36
|
+
expect(expired_result).to eq("third_value")
|
|
37
|
+
end
|
|
21
38
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
39
|
+
it "returns fresh data when no expires_in is provided" do
|
|
40
|
+
# rubocop:disable Style/RedundantFetchBlock
|
|
41
|
+
result = cache.fetch("no_expiry_key") { "value_1" }
|
|
42
|
+
expect(result).to eq("value_1")
|
|
43
|
+
|
|
44
|
+
# Without expiration, values stay cached indefinitely
|
|
45
|
+
cached_result = cache.fetch("no_expiry_key") { "value_2" }
|
|
46
|
+
expect(cached_result).to eq("value_1")
|
|
47
|
+
# rubocop:enable Style/RedundantFetchBlock
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "handles different cache keys independently" do
|
|
51
|
+
result1 = cache.fetch("key_1", expires_in: 60) { "value_1" }
|
|
52
|
+
result2 = cache.fetch("key_2", expires_in: 60) { "value_2" }
|
|
53
|
+
|
|
54
|
+
expect(result1).to eq("value_1")
|
|
55
|
+
expect(result2).to eq("value_2")
|
|
56
|
+
|
|
57
|
+
# Both should be cached
|
|
58
|
+
cached_result1 = cache.fetch("key_1", expires_in: 60) { "new_value_1" }
|
|
59
|
+
cached_result2 = cache.fetch("key_2", expires_in: 60) { "new_value_2" }
|
|
60
|
+
|
|
61
|
+
expect(cached_result1).to eq("value_1")
|
|
62
|
+
expect(cached_result2).to eq("value_2")
|
|
63
|
+
end
|
|
26
64
|
end
|
|
27
65
|
end
|
|
@@ -5,24 +5,137 @@ require "currency_converter"
|
|
|
5
5
|
RSpec.describe CurrencyConverter::Converter do
|
|
6
6
|
let(:converter) { described_class.new }
|
|
7
7
|
|
|
8
|
-
#
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
describe "#convert" do
|
|
9
|
+
context "with valid inputs" do
|
|
10
|
+
before do
|
|
11
|
+
allow_any_instance_of(CurrencyConverter::APIClient)
|
|
12
|
+
.to receive(:get_rate)
|
|
13
|
+
.and_return(0.85)
|
|
14
|
+
end
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
it "performs the conversion successfully" do
|
|
17
|
+
result = converter.convert(100, "USD", "EUR")
|
|
18
|
+
expect(result).to eq(85.0)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "handles zero amount" do
|
|
22
|
+
result = converter.convert(0, "USD", "EUR")
|
|
23
|
+
expect(result).to eq(0.0)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "handles decimal amounts" do
|
|
27
|
+
result = converter.convert(100.50, "USD", "EUR")
|
|
28
|
+
expect(result).to eq(85.43)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
context "with API errors" do
|
|
33
|
+
before do
|
|
34
|
+
allow_any_instance_of(CurrencyConverter::APIClient)
|
|
35
|
+
.to receive(:get_rate)
|
|
36
|
+
.and_raise(CurrencyConverter::RateNotFoundError, "EUR rate not available")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it "raises an APIError if the conversion fails due to missing rate" do
|
|
40
|
+
expect { converter.convert(100, "USD", "EUR") }
|
|
41
|
+
.to raise_error(CurrencyConverter::APIError, /Conversion failed/)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "logs error when conversion fails" do
|
|
45
|
+
logger = instance_double(Logger, error: nil, info: nil)
|
|
46
|
+
allow(CurrencyConverter.configuration).to receive(:logger).and_return(logger)
|
|
47
|
+
|
|
48
|
+
expect(logger).to receive(:error).with(/Conversion failed/)
|
|
49
|
+
|
|
50
|
+
begin
|
|
51
|
+
converter.convert(100, "USD", "EUR")
|
|
52
|
+
rescue CurrencyConverter::APIError
|
|
53
|
+
# Expected error
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it "re-raises StandardError exceptions with logging" do
|
|
58
|
+
allow_any_instance_of(CurrencyConverter::APIClient)
|
|
59
|
+
.to receive(:get_rate)
|
|
60
|
+
.and_raise(StandardError, "Unexpected error")
|
|
61
|
+
|
|
62
|
+
logger = instance_double(Logger, error: nil, info: nil)
|
|
63
|
+
allow(CurrencyConverter.configuration).to receive(:logger).and_return(logger)
|
|
64
|
+
|
|
65
|
+
expect(logger).to receive(:error).with(/Conversion failed/)
|
|
66
|
+
|
|
67
|
+
expect { converter.convert(100, "USD", "EUR") }
|
|
68
|
+
.to raise_error(StandardError, "Unexpected error")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
context "with invalid amount" do
|
|
73
|
+
it "raises InvalidAmountError when amount is nil" do
|
|
74
|
+
expect { converter.convert(nil, "USD", "EUR") }
|
|
75
|
+
.to raise_error(CurrencyConverter::InvalidAmountError, "Amount cannot be nil")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "does not log validation errors" do
|
|
79
|
+
logger = instance_double(Logger, error: nil, info: nil)
|
|
80
|
+
allow(CurrencyConverter.configuration).to receive(:logger).and_return(logger)
|
|
81
|
+
|
|
82
|
+
expect(logger).not_to receive(:error)
|
|
83
|
+
|
|
84
|
+
expect { converter.convert(nil, "USD", "EUR") }
|
|
85
|
+
.to raise_error(CurrencyConverter::InvalidAmountError)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "raises InvalidAmountError when amount is not numeric" do
|
|
89
|
+
expect { converter.convert("100", "USD", "EUR") }
|
|
90
|
+
.to raise_error(CurrencyConverter::InvalidAmountError, /Amount must be a number/)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "raises InvalidAmountError when amount is negative" do
|
|
94
|
+
expect { converter.convert(-100, "USD", "EUR") }
|
|
95
|
+
.to raise_error(CurrencyConverter::InvalidAmountError, /Amount cannot be negative/)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
context "with invalid currency codes" do
|
|
100
|
+
it "raises InvalidCurrencyError when from_currency is nil" do
|
|
101
|
+
expect { converter.convert(100, nil, "EUR") }
|
|
102
|
+
.to raise_error(CurrencyConverter::InvalidCurrencyError, "from_currency cannot be nil")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "raises InvalidCurrencyError when to_currency is nil" do
|
|
106
|
+
expect { converter.convert(100, "USD", nil) }
|
|
107
|
+
.to raise_error(CurrencyConverter::InvalidCurrencyError, "to_currency cannot be nil")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it "raises InvalidCurrencyError when from_currency is not a string" do
|
|
111
|
+
expect { converter.convert(100, 123, "EUR") }
|
|
112
|
+
.to raise_error(CurrencyConverter::InvalidCurrencyError, /from_currency must be a string/)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "raises InvalidCurrencyError when from_currency is empty" do
|
|
116
|
+
expect { converter.convert(100, "", "EUR") }
|
|
117
|
+
.to raise_error(CurrencyConverter::InvalidCurrencyError, "from_currency cannot be empty")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "raises InvalidCurrencyError when from_currency is not 3 letters" do
|
|
121
|
+
expect { converter.convert(100, "US", "EUR") }
|
|
122
|
+
.to raise_error(CurrencyConverter::InvalidCurrencyError, /must be a 3-letter uppercase code/)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it "raises InvalidCurrencyError when from_currency is not uppercase" do
|
|
126
|
+
expect { converter.convert(100, "usd", "EUR") }
|
|
127
|
+
.to raise_error(CurrencyConverter::InvalidCurrencyError, /must be a 3-letter uppercase code/)
|
|
128
|
+
end
|
|
18
129
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
.and_return(0.85)
|
|
130
|
+
it "raises InvalidCurrencyError when from_currency contains numbers" do
|
|
131
|
+
expect { converter.convert(100, "US1", "EUR") }
|
|
132
|
+
.to raise_error(CurrencyConverter::InvalidCurrencyError, /must be a 3-letter uppercase code/)
|
|
133
|
+
end
|
|
24
134
|
|
|
25
|
-
|
|
26
|
-
|
|
135
|
+
it "raises InvalidCurrencyError when to_currency is invalid" do
|
|
136
|
+
expect { converter.convert(100, "USD", "euro") }
|
|
137
|
+
.to raise_error(CurrencyConverter::InvalidCurrencyError, /must be a 3-letter uppercase code/)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
27
140
|
end
|
|
28
141
|
end
|