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.
@@ -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
- # Initializes the API client with the provided or configured API key.
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
- @cache.fetch("#{from_currency}_#{to_currency}") do
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CurrencyConverter
4
- VERSION = "1.1.0"
4
+ VERSION = "1.2.1"
5
5
  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
- it "returns the exchange rate for a valid currency pair" do
11
- stub_request(:get, "https://api.exchangerate-api.com/v4/latest/USD")
12
- .to_return(body: { rates: { "EUR" => 0.85 } }.to_json)
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
- expect(client.get_rate("USD", "EUR")).to eq(0.85)
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
- it "raises a RateNotFoundError if the rate is missing" do
18
- stub_request(:get, "https://api.exchangerate-api.com/v4/latest/USD")
19
- .to_return(body: { rates: {} }.to_json)
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
- expect { client.get_rate("USD", "EUR") }.to raise_error(CurrencyConverter::RateNotFoundError)
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
- it "caches values for the specified duration" do
17
- # Cache a value for the key "test_key"
18
- # rubocop:disable Style/RedundantFetchBlock
19
- result = cache.fetch("test_key") { 100 }
20
- expect(result).to eq(100)
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
- # Try fetching the value again, it should return the cached value
23
- cached_result = cache.fetch("test_key") { 200 }
24
- expect(cached_result).to eq(100) # Cached value should be returned
25
- # rubocop:enable Style/RedundantFetchBlock
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
- # Simulate a missing exchange rate to trigger the error handling
9
- before do
10
- allow_any_instance_of(CurrencyConverter::APIClient)
11
- .to receive(:get_rate)
12
- .and_raise(CurrencyConverter::RateNotFoundError, "EUR rate not available")
13
- end
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
- it "raises an APIError if the conversion fails due to missing rate" do
16
- expect { converter.convert(100, "USD", "EUR") }.to raise_error(CurrencyConverter::APIError, /Conversion failed/)
17
- end
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
- it "performs the conversion successfully when rate is available" do
20
- # Simulate a successful exchange rate retrieval
21
- allow_any_instance_of(CurrencyConverter::APIClient)
22
- .to receive(:get_rate)
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
- result = converter.convert(100, "USD", "EUR")
26
- expect(result).to eq(85.0)
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