currency-converter 1.1.0 → 1.2.0
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 +6 -0
- data/.rubocop.yml +18 -0
- data/CHANGELOG.md +48 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +9 -1
- data/README.md +150 -40
- data/currency-converter.gemspec +1 -1
- 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 +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 41b3778d46388a2a405491288e53299f646d91033c38905f1ff9b8b2a6022ecc
|
|
4
|
+
data.tar.gz: ea8d42edb7f824133e28abb514022a5aa217e1e8df877193bd310864baa175f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e7eb0dbca550cb87b40e2dfad8c071371cdd29a82ba4f306fd8b063494f3d53e00f0023787d3525cd0d09195394bc2844fb8ff45028c32419997129dcb729747
|
|
7
|
+
data.tar.gz: 8332235cd209a3121077cd751b383a8505f46658d7e2ff2413238fedd9ae374a277ae423ddec37488ea83e60ecb9f6c9beed3c7cd9e1650ed0c00e477bfb9f28
|
data/.gitignore
CHANGED
data/.rubocop.yml
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
AllCops:
|
|
2
2
|
TargetRubyVersion: 2.6
|
|
3
|
+
NewCops: enable
|
|
4
|
+
SuggestExtensions: false
|
|
3
5
|
|
|
4
6
|
Style/StringLiterals:
|
|
5
7
|
Enabled: true
|
|
@@ -11,3 +13,19 @@ Style/StringLiteralsInInterpolation:
|
|
|
11
13
|
|
|
12
14
|
Layout/LineLength:
|
|
13
15
|
Max: 120
|
|
16
|
+
|
|
17
|
+
# Allow longer methods for complex logic
|
|
18
|
+
Metrics/MethodLength:
|
|
19
|
+
Max: 15
|
|
20
|
+
Exclude:
|
|
21
|
+
- 'lib/currency_converter/api_client.rb'
|
|
22
|
+
- 'lib/currency_converter/converter.rb'
|
|
23
|
+
|
|
24
|
+
# Allow longer blocks in tests
|
|
25
|
+
Metrics/BlockLength:
|
|
26
|
+
Exclude:
|
|
27
|
+
- 'spec/**/*_spec.rb'
|
|
28
|
+
|
|
29
|
+
# Disable for generated or gem metadata files
|
|
30
|
+
Gemspec/RequireMFA:
|
|
31
|
+
Enabled: false
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,51 @@
|
|
|
1
|
+
## [1.2.0] - 2026-01-10
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
- **v6 API Support**: Migrated from deprecated v4 to v6 ExchangeRate-API
|
|
5
|
+
- Dual-mode operation: authenticated (with API key) and open access (without API key)
|
|
6
|
+
- API key is now optional - gem works perfectly without it
|
|
7
|
+
- Improved error handling for v6-specific errors (invalid-key, quota-reached, inactive-account, unsupported-code)
|
|
8
|
+
- Helpful logging to inform users which API mode they're using
|
|
9
|
+
- **Input Validation**: Comprehensive validation for all conversion inputs
|
|
10
|
+
- Validates amounts (rejects nil, negative, and non-numeric values)
|
|
11
|
+
- Validates currency codes (enforces 3-letter uppercase ISO 4217 format)
|
|
12
|
+
- New error classes: `InvalidAmountError` and `InvalidCurrencyError`
|
|
13
|
+
- Clear, actionable error messages for debugging
|
|
14
|
+
- **HTTP Timeout Configuration**: Configurable timeout to prevent hanging requests
|
|
15
|
+
- Default timeout: 10 seconds
|
|
16
|
+
- Prevents indefinite hangs on slow/dead connections
|
|
17
|
+
- New `TimeoutError` exception for timeout scenarios
|
|
18
|
+
- Configurable via `CurrencyConverter.configure { |c| c.timeout = 15 }`
|
|
19
|
+
- **Test Infrastructure**: Achieved 100% code coverage
|
|
20
|
+
- SimpleCov integration for coverage tracking
|
|
21
|
+
- 43 comprehensive test examples covering all code paths
|
|
22
|
+
- Manual integration test script for real API verification
|
|
23
|
+
- Zero RuboCop offenses
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- **Cache Duration Bug**: Cache expiration now works correctly
|
|
27
|
+
- Previously configured `cache_duration` was ignored
|
|
28
|
+
- Cache now properly expires after the configured duration
|
|
29
|
+
- Fixed by passing `expires_in` parameter to ActiveSupport::Cache
|
|
30
|
+
- **API Key Usage**: API key is now properly used in authenticated requests
|
|
31
|
+
- v1.1.0 configured but never used the API key
|
|
32
|
+
- v6 authenticated mode now includes key in URL path
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
- **Breaking Change**: Migrated from v4 to v6 API
|
|
36
|
+
- v4 endpoint: `https://api.exchangerate-api.com/v4/latest/{currency}`
|
|
37
|
+
- v6 authenticated: `https://v6.exchangerate-api.com/v6/{API_KEY}/latest/{currency}`
|
|
38
|
+
- v6 open access: `https://open.er-api.com/v6/latest/{currency}`
|
|
39
|
+
- No breaking changes for existing users - works with or without API key
|
|
40
|
+
- Code quality improvements: All RuboCop offenses fixed
|
|
41
|
+
|
|
42
|
+
### Migration Guide from v1.1.0 to v1.2.0
|
|
43
|
+
- **No code changes required** - fully backward compatible
|
|
44
|
+
- API key is now optional (but recommended for better rate limits)
|
|
45
|
+
- Invalid inputs (nil, negative amounts, invalid currency codes) will now raise validation errors
|
|
46
|
+
- Cache will now properly expire after configured duration (was broken in v1.1.0)
|
|
47
|
+
- Set timeout if needed: `CurrencyConverter.configure { |c| c.timeout = 15 }`
|
|
48
|
+
|
|
1
49
|
## [1.1.0] - 2024-11-13
|
|
2
50
|
### Added
|
|
3
51
|
- Added caching support for better performance.
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
currency-converter (1.
|
|
4
|
+
currency-converter (1.2.0)
|
|
5
5
|
|
|
6
6
|
GEM
|
|
7
7
|
remote: https://rubygems.org/
|
|
@@ -21,6 +21,7 @@ GEM
|
|
|
21
21
|
bigdecimal
|
|
22
22
|
rexml
|
|
23
23
|
diff-lcs (1.5.1)
|
|
24
|
+
docile (1.4.1)
|
|
24
25
|
hashdiff (1.1.2)
|
|
25
26
|
i18n (1.14.6)
|
|
26
27
|
concurrent-ruby (~> 1.0)
|
|
@@ -63,6 +64,12 @@ GEM
|
|
|
63
64
|
rubocop-ast (1.35.0)
|
|
64
65
|
parser (>= 3.3.1.0)
|
|
65
66
|
ruby-progressbar (1.13.0)
|
|
67
|
+
simplecov (0.22.0)
|
|
68
|
+
docile (~> 1.1)
|
|
69
|
+
simplecov-html (~> 0.11)
|
|
70
|
+
simplecov_json_formatter (~> 0.1)
|
|
71
|
+
simplecov-html (0.13.2)
|
|
72
|
+
simplecov_json_formatter (0.1.4)
|
|
66
73
|
stringio (3.1.2)
|
|
67
74
|
tzinfo (2.0.6)
|
|
68
75
|
concurrent-ruby (~> 1.0)
|
|
@@ -83,6 +90,7 @@ DEPENDENCIES
|
|
|
83
90
|
rake (~> 13.0)
|
|
84
91
|
rspec (~> 3.0)
|
|
85
92
|
rubocop (~> 1.21)
|
|
93
|
+
simplecov
|
|
86
94
|
stringio (~> 3.1.2)
|
|
87
95
|
webmock
|
|
88
96
|
|
data/README.md
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# CurrencyConverter
|
|
2
2
|
|
|
3
|
-
The CurrencyConverter gem provides an easy way to perform currency conversions. It allows you to convert amounts between different currencies using real-time exchange rates from
|
|
3
|
+
The CurrencyConverter gem provides an easy way to perform currency conversions. It allows you to convert amounts between different currencies using real-time exchange rates from ExchangeRate-API, while offering caching for performance improvements and comprehensive error handling for robustness.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
**Version 1.2.0** brings significant improvements including v6 API support, input validation, HTTP timeout protection, and 100% test coverage.
|
|
6
|
+
|
|
7
|
+
Author: Developed by [Shobhit Jain](https://github.com/shobhits7).
|
|
7
8
|
|
|
8
9
|
## Installation
|
|
9
10
|
|
|
10
11
|
Add this line to your application's Gemfile:
|
|
11
12
|
|
|
12
13
|
```ruby
|
|
13
|
-
gem 'currency-converter', '~>
|
|
14
|
+
gem 'currency-converter', '~> 1.2.0'
|
|
14
15
|
```
|
|
15
16
|
|
|
16
17
|
And then execute:
|
|
@@ -23,101 +24,210 @@ Or install it yourself as:
|
|
|
23
24
|
|
|
24
25
|
## Configuration
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
The gem can be used with or without configuration. For better rate limits and performance, it's recommended to configure it with an API key.
|
|
28
|
+
|
|
29
|
+
### Basic Usage (No Configuration Required)
|
|
30
|
+
|
|
31
|
+
The gem works out of the box using the v6 open access API:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
converter = CurrencyConverter::Converter.new
|
|
35
|
+
amount_in_eur = converter.convert(100, 'USD', 'EUR')
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Advanced Configuration (Recommended)
|
|
27
39
|
|
|
28
|
-
|
|
29
|
-
To get your api key, you can sign up for free at [Exchange Rate API](https://www.exchangerate-api.com/)
|
|
40
|
+
To get your free API key, sign up at [Exchange Rate API](https://www.exchangerate-api.com/)
|
|
30
41
|
|
|
31
42
|
```ruby
|
|
32
43
|
CurrencyConverter.configure do |config|
|
|
33
|
-
config.api_key = 'your_api_key_here' #
|
|
34
|
-
config.cache_duration =
|
|
44
|
+
config.api_key = 'your_api_key_here' # Optional: For better rate limits (v6 authenticated API)
|
|
45
|
+
config.cache_duration = 3600 # Optional: Cache duration in seconds (default: 1 hour)
|
|
46
|
+
config.timeout = 10 # Optional: HTTP timeout in seconds (default: 10)
|
|
35
47
|
config.logger = Logger.new(STDOUT) # Optional: Configure logging
|
|
36
48
|
end
|
|
37
49
|
```
|
|
38
50
|
|
|
39
|
-
|
|
51
|
+
**Configuration Options:**
|
|
40
52
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
- **api_key** (Optional): Your API key for v6 authenticated access with higher rate limits. Without it, the gem uses the v6 open access API (free tier).
|
|
54
|
+
- **cache_duration** (Optional): Duration in seconds for caching exchange rates. Default: 3600 seconds (1 hour).
|
|
55
|
+
- **timeout** (Optional): HTTP request timeout in seconds. Prevents hanging on slow connections. Default: 10 seconds.
|
|
56
|
+
- **logger** (Optional): Logger instance for debugging and monitoring. Default: Logger writing to STDOUT.
|
|
44
57
|
|
|
45
58
|
|
|
46
59
|
## Usage
|
|
47
|
-
|
|
48
|
-
|
|
60
|
+
|
|
61
|
+
### Creating an Instance of the Converter
|
|
62
|
+
|
|
63
|
+
Create an instance of the `CurrencyConverter::Converter` class to perform currency conversions:
|
|
49
64
|
|
|
50
65
|
```ruby
|
|
51
|
-
# Initialize the converter with the configured API key
|
|
52
66
|
converter = CurrencyConverter::Converter.new
|
|
53
67
|
```
|
|
54
68
|
|
|
55
69
|
### Converting Currency
|
|
56
70
|
|
|
57
|
-
Use the convert method to convert an amount from one currency to another
|
|
71
|
+
Use the `convert` method to convert an amount from one currency to another:
|
|
58
72
|
|
|
59
73
|
```ruby
|
|
60
74
|
# Convert 100 USD to EUR
|
|
61
75
|
amount_in_eur = converter.convert(100, 'USD', 'EUR')
|
|
62
|
-
puts amount_in_eur
|
|
76
|
+
puts amount_in_eur # => 85.0 (example rate)
|
|
77
|
+
|
|
78
|
+
# Convert with decimal amounts
|
|
79
|
+
amount_in_gbp = converter.convert(99.99, 'USD', 'GBP')
|
|
80
|
+
puts amount_in_gbp # => 75.99 (example rate)
|
|
63
81
|
```
|
|
64
82
|
|
|
65
|
-
|
|
83
|
+
**Parameters:**
|
|
84
|
+
|
|
85
|
+
- **amount** (Numeric): The amount to convert. Must be a positive number (Integer or Float).
|
|
86
|
+
- **from_currency** (String): Source currency code in ISO 4217 format (3 uppercase letters, e.g., "USD").
|
|
87
|
+
- **to_currency** (String): Target currency code in ISO 4217 format (3 uppercase letters, e.g., "EUR").
|
|
66
88
|
|
|
67
|
-
|
|
89
|
+
**Returns:** The converted amount as a Float, rounded to 2 decimal places.
|
|
68
90
|
|
|
69
|
-
|
|
91
|
+
### Input Validation
|
|
92
|
+
|
|
93
|
+
Version 1.2.0 includes comprehensive input validation:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
# Valid inputs
|
|
97
|
+
converter.convert(100, 'USD', 'EUR') # ✓ Valid
|
|
98
|
+
converter.convert(0, 'USD', 'EUR') # ✓ Valid (zero is allowed)
|
|
99
|
+
converter.convert(99.99, 'USD', 'GBP') # ✓ Valid (decimals allowed)
|
|
100
|
+
|
|
101
|
+
# Invalid inputs that will raise errors
|
|
102
|
+
converter.convert(nil, 'USD', 'EUR') # ✗ Raises InvalidAmountError
|
|
103
|
+
converter.convert(-100, 'USD', 'EUR') # ✗ Raises InvalidAmountError
|
|
104
|
+
converter.convert('100', 'USD', 'EUR') # ✗ Raises InvalidAmountError
|
|
105
|
+
converter.convert(100, 'usd', 'EUR') # ✗ Raises InvalidCurrencyError (must be uppercase)
|
|
106
|
+
converter.convert(100, 'US', 'EUR') # ✗ Raises InvalidCurrencyError (must be 3 letters)
|
|
107
|
+
converter.convert(100, nil, 'EUR') # ✗ Raises InvalidCurrencyError
|
|
108
|
+
```
|
|
70
109
|
|
|
71
|
-
|
|
110
|
+
## Error Handling
|
|
72
111
|
|
|
73
|
-
|
|
74
|
-
The convert method raises errors in the following situations:
|
|
112
|
+
Version 1.2.0 provides comprehensive error handling with specific exception types:
|
|
75
113
|
|
|
76
|
-
|
|
114
|
+
### Exception Types
|
|
77
115
|
|
|
78
|
-
|
|
116
|
+
- **`InvalidAmountError`**: Raised when the amount is invalid (nil, negative, or non-numeric)
|
|
117
|
+
- **`InvalidCurrencyError`**: Raised when currency codes are invalid (nil, wrong format, not ISO 4217)
|
|
118
|
+
- **`TimeoutError`**: Raised when the API request times out
|
|
119
|
+
- **`APIError`**: Raised for API-related failures (network errors, missing rates, API quota reached)
|
|
120
|
+
- **`RateNotFoundError`**: Raised when a specific currency rate is not available from the API
|
|
79
121
|
|
|
80
|
-
|
|
122
|
+
### Error Handling Examples
|
|
81
123
|
|
|
82
124
|
```ruby
|
|
83
125
|
begin
|
|
84
126
|
amount_in_eur = converter.convert(100, 'USD', 'EUR')
|
|
85
127
|
puts "Converted amount: #{amount_in_eur} EUR"
|
|
128
|
+
rescue CurrencyConverter::InvalidAmountError => e
|
|
129
|
+
puts "Invalid amount: #{e.message}"
|
|
130
|
+
rescue CurrencyConverter::InvalidCurrencyError => e
|
|
131
|
+
puts "Invalid currency: #{e.message}"
|
|
132
|
+
rescue CurrencyConverter::TimeoutError => e
|
|
133
|
+
puts "Request timed out: #{e.message}"
|
|
86
134
|
rescue CurrencyConverter::APIError => e
|
|
87
|
-
puts "
|
|
135
|
+
puts "API error: #{e.message}"
|
|
88
136
|
rescue StandardError => e
|
|
89
|
-
puts "
|
|
137
|
+
puts "Unexpected error: #{e.message}"
|
|
90
138
|
end
|
|
91
139
|
```
|
|
92
140
|
|
|
141
|
+
### Common Error Messages
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
# Invalid amount errors
|
|
145
|
+
"Amount cannot be nil"
|
|
146
|
+
"Amount must be a number"
|
|
147
|
+
"Amount cannot be negative"
|
|
148
|
+
|
|
149
|
+
# Invalid currency errors
|
|
150
|
+
"from_currency cannot be nil"
|
|
151
|
+
"from_currency must be a 3-letter uppercase code (e.g., 'USD')"
|
|
152
|
+
"to_currency must be a 3-letter uppercase code (e.g., 'EUR')"
|
|
153
|
+
|
|
154
|
+
# API errors
|
|
155
|
+
"Request timed out after 10 seconds"
|
|
156
|
+
"Invalid API key provided"
|
|
157
|
+
"API rate limit quota reached"
|
|
158
|
+
"Unsupported currency code: XYZ"
|
|
159
|
+
"EUR rate not available"
|
|
160
|
+
```
|
|
161
|
+
|
|
93
162
|
## Testing
|
|
94
163
|
|
|
95
|
-
The gem
|
|
164
|
+
The gem has **100% test coverage** with comprehensive test suites covering all functionality.
|
|
165
|
+
|
|
166
|
+
### Running Tests
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
# Run all tests
|
|
170
|
+
bundle exec rspec
|
|
171
|
+
|
|
172
|
+
# Run with coverage report
|
|
173
|
+
bundle exec rspec
|
|
174
|
+
# View coverage report: open coverage/index.html
|
|
175
|
+
|
|
176
|
+
# Run code quality checks
|
|
177
|
+
bundle exec rubocop
|
|
178
|
+
|
|
179
|
+
# Run manual integration tests (with real API)
|
|
180
|
+
ruby manual_integration_test.rb
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Test Coverage
|
|
184
|
+
|
|
185
|
+
**Version 1.2.0 Test Statistics:**
|
|
186
|
+
- **43 test examples**, 0 failures
|
|
187
|
+
- **100% code coverage** (125/125 lines)
|
|
188
|
+
- **0 RuboCop offenses**
|
|
189
|
+
- All tests use WebMock to stub external API calls for consistency
|
|
190
|
+
- Manual integration test script included for real API verification
|
|
191
|
+
|
|
192
|
+
### Example Test
|
|
96
193
|
|
|
97
|
-
Example Test:
|
|
98
194
|
```ruby
|
|
99
195
|
RSpec.describe CurrencyConverter::Converter do
|
|
100
196
|
let(:converter) { CurrencyConverter::Converter.new }
|
|
101
197
|
|
|
102
198
|
it 'performs currency conversion successfully' do
|
|
199
|
+
# WebMock stubs the API call
|
|
200
|
+
allow_any_instance_of(CurrencyConverter::APIClient)
|
|
201
|
+
.to receive(:get_rate)
|
|
202
|
+
.and_return(0.85)
|
|
203
|
+
|
|
103
204
|
result = converter.convert(100, 'USD', 'EUR')
|
|
104
|
-
expect(result).to
|
|
205
|
+
expect(result).to eq(85.0)
|
|
105
206
|
end
|
|
106
207
|
|
|
107
|
-
it 'raises
|
|
108
|
-
expect { converter.convert(100, 'USD', '
|
|
208
|
+
it 'raises InvalidAmountError for negative amounts' do
|
|
209
|
+
expect { converter.convert(-100, 'USD', 'EUR') }
|
|
210
|
+
.to raise_error(CurrencyConverter::InvalidAmountError)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
it 'raises InvalidCurrencyError for invalid currency codes' do
|
|
214
|
+
expect { converter.convert(100, 'usd', 'EUR') }
|
|
215
|
+
.to raise_error(CurrencyConverter::InvalidCurrencyError)
|
|
109
216
|
end
|
|
110
217
|
end
|
|
111
218
|
```
|
|
112
219
|
|
|
113
|
-
## Test Setup:
|
|
114
|
-
Ensure you have the required API key and any necessary configuration before running the tests. Mocking external API calls in tests is recommended for consistent results.
|
|
115
|
-
|
|
116
220
|
## Changelog
|
|
117
|
-
> v1.0.0
|
|
118
221
|
|
|
119
|
-
|
|
120
|
-
|
|
222
|
+
See [CHANGELOG.md](CHANGELOG.md) for detailed version history and release notes.
|
|
223
|
+
|
|
224
|
+
**Latest Release (v1.2.0 - 2026-01-10):**
|
|
225
|
+
- Migrated to ExchangeRate-API v6 with dual-mode support
|
|
226
|
+
- Added comprehensive input validation
|
|
227
|
+
- Fixed cache duration implementation
|
|
228
|
+
- Added HTTP timeout configuration
|
|
229
|
+
- Achieved 100% test coverage
|
|
230
|
+
- Zero code quality issues
|
|
121
231
|
|
|
122
232
|
## Development
|
|
123
233
|
|
data/currency-converter.gemspec
CHANGED
|
@@ -20,7 +20,7 @@ Gem::Specification.new do |spec|
|
|
|
20
20
|
|
|
21
21
|
# Specify which files should be added to the gem when it is released.
|
|
22
22
|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
23
|
-
spec.files = `git ls-files -z`.split("\x0").
|
|
23
|
+
spec.files = `git ls-files -z`.split("\x0").grep_v(/\.gem$/)
|
|
24
24
|
|
|
25
25
|
# spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
26
26
|
# `git ls-files -z`.split("\x0").reject do |f|
|
|
@@ -7,28 +7,108 @@ require_relative "errors"
|
|
|
7
7
|
module CurrencyConverter
|
|
8
8
|
# The APIClient class handles API requests to the external exchange rate provider.
|
|
9
9
|
# Responsible for retrieving exchange rates based on the provided currencies.
|
|
10
|
+
# Supports both v6 authenticated (with API key) and v6 open access (without API key) endpoints.
|
|
10
11
|
class APIClient
|
|
11
|
-
|
|
12
|
+
# v6 authenticated endpoint (requires API key, better rate limits)
|
|
13
|
+
AUTHENTICATED_BASE_URL = "https://v6.exchangerate-api.com/v6"
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
# v6 open access endpoint (no API key required, lower rate limits)
|
|
16
|
+
OPEN_ACCESS_BASE_URL = "https://open.er-api.com/v6/latest"
|
|
17
|
+
|
|
18
|
+
def initialize(api_key, timeout: CurrencyConverter.configuration&.timeout || 10)
|
|
14
19
|
@api_key = api_key
|
|
20
|
+
@timeout = timeout
|
|
21
|
+
@use_authenticated = !api_key.nil? && !api_key.empty?
|
|
22
|
+
|
|
23
|
+
log_api_mode if CurrencyConverter.configuration&.logger
|
|
15
24
|
end
|
|
16
25
|
|
|
17
26
|
# Gets the exchange rate for a currency pair.
|
|
18
27
|
# @param from_currency [String] the source currency code
|
|
19
28
|
# @param to_currency [String] the target currency code
|
|
20
29
|
# @return [Float] the exchange rate
|
|
21
|
-
# @raise [RateNotFoundError, APIError] for invalid responses
|
|
30
|
+
# @raise [RateNotFoundError, APIError, TimeoutError] for invalid responses, network issues, or timeout
|
|
22
31
|
def get_rate(from_currency, to_currency)
|
|
23
|
-
url =
|
|
24
|
-
|
|
32
|
+
url = build_url(from_currency)
|
|
33
|
+
uri = URI(url)
|
|
34
|
+
|
|
35
|
+
response = fetch_with_timeout(uri)
|
|
25
36
|
data = JSON.parse(response)
|
|
26
37
|
|
|
27
|
-
|
|
38
|
+
# Check for API-specific errors in v6 response
|
|
39
|
+
handle_v6_errors(data)
|
|
40
|
+
|
|
41
|
+
# Extract rate from v6 response format
|
|
42
|
+
rate = data.dig("conversion_rates", to_currency) || data.dig("rates", to_currency)
|
|
43
|
+
raise(RateNotFoundError, "#{to_currency} rate not available") unless rate
|
|
44
|
+
|
|
45
|
+
rate
|
|
28
46
|
rescue JSON::ParserError
|
|
29
47
|
raise APIError, "Invalid API response format"
|
|
48
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
49
|
+
raise TimeoutError, "Request timed out after #{@timeout} seconds: #{e.message}"
|
|
30
50
|
rescue Net::HTTPError, SocketError => e
|
|
31
51
|
raise APIError, "Network error: #{e.message}"
|
|
32
52
|
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Builds the appropriate URL based on authentication mode
|
|
57
|
+
# @param from_currency [String] the base currency code
|
|
58
|
+
# @return [String] the complete API URL
|
|
59
|
+
def build_url(from_currency)
|
|
60
|
+
if @use_authenticated
|
|
61
|
+
"#{AUTHENTICATED_BASE_URL}/#{@api_key}/latest/#{from_currency}"
|
|
62
|
+
else
|
|
63
|
+
"#{OPEN_ACCESS_BASE_URL}/#{from_currency}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Handles v6-specific error responses
|
|
68
|
+
# @param data [Hash] the parsed JSON response
|
|
69
|
+
# @raise [APIError] for various v6 API errors
|
|
70
|
+
def handle_v6_errors(data)
|
|
71
|
+
return unless data["result"] == "error"
|
|
72
|
+
|
|
73
|
+
error_type = data["error-type"]
|
|
74
|
+
case error_type
|
|
75
|
+
when "invalid-key"
|
|
76
|
+
raise APIError, "Invalid API key provided"
|
|
77
|
+
when "inactive-account"
|
|
78
|
+
raise APIError, "API account is inactive"
|
|
79
|
+
when "quota-reached"
|
|
80
|
+
raise APIError, "API rate limit quota reached"
|
|
81
|
+
when "unsupported-code"
|
|
82
|
+
raise APIError, "Unsupported currency code"
|
|
83
|
+
else
|
|
84
|
+
raise APIError, "API error: #{error_type || "unknown error"}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Fetches data from the URI with timeout configuration
|
|
89
|
+
# @param uri [URI] the URI to fetch from
|
|
90
|
+
# @return [String] the response body
|
|
91
|
+
def fetch_with_timeout(uri)
|
|
92
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
93
|
+
open_timeout: @timeout, read_timeout: @timeout) do |http|
|
|
94
|
+
request = Net::HTTP::Get.new(uri)
|
|
95
|
+
response = http.request(request)
|
|
96
|
+
response.body
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Logs the API mode being used
|
|
101
|
+
def log_api_mode
|
|
102
|
+
if @use_authenticated
|
|
103
|
+
CurrencyConverter.configuration.logger.info(
|
|
104
|
+
"CurrencyConverter: Using v6 authenticated API (better rate limits)"
|
|
105
|
+
)
|
|
106
|
+
else
|
|
107
|
+
CurrencyConverter.configuration.logger.info(
|
|
108
|
+
"CurrencyConverter: Using v6 open access API (free tier). " \
|
|
109
|
+
"Configure an API key for better rate limits: https://www.exchangerate-api.com"
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
33
113
|
end
|
|
34
114
|
end
|
|
@@ -13,10 +13,11 @@ module CurrencyConverter
|
|
|
13
13
|
|
|
14
14
|
# Fetches the value from the cache, using the block to provide a default value.
|
|
15
15
|
# @param key [String] the cache key
|
|
16
|
+
# @param expires_in [Integer] the cache expiration time in seconds
|
|
16
17
|
# @param block [Proc] the block to execute if the key is not found in cache
|
|
17
18
|
# @return [Object] the cached value
|
|
18
|
-
def fetch(key, &block)
|
|
19
|
-
@cache.fetch(key, &block)
|
|
19
|
+
def fetch(key, expires_in: nil, &block)
|
|
20
|
+
@cache.fetch(key, expires_in: expires_in, &block)
|
|
20
21
|
end
|
|
21
22
|
end
|
|
22
23
|
end
|
|
@@ -10,12 +10,13 @@ module CurrencyConverter
|
|
|
10
10
|
# - `cache_duration`: Duration for which the exchange rates should be cached.
|
|
11
11
|
# - `logger`: Configurable logger for logging any errors or information.
|
|
12
12
|
class Configuration
|
|
13
|
-
attr_accessor :api_key, :cache_duration, :logger
|
|
13
|
+
attr_accessor :api_key, :cache_duration, :logger, :timeout
|
|
14
14
|
|
|
15
15
|
def initialize
|
|
16
|
-
@api_key = ENV
|
|
16
|
+
@api_key = ENV.fetch("CURRENCY_CONVERTER_API_KEY", nil)
|
|
17
17
|
@cache_duration = 1.hour # Default cache duration is 1 hour
|
|
18
18
|
@logger = Logger.new($stdout)
|
|
19
|
+
@timeout = 10 # Default timeout is 10 seconds
|
|
19
20
|
end
|
|
20
21
|
end
|
|
21
22
|
|
|
@@ -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
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "currency_converter"
|
|
4
|
+
|
|
5
|
+
RSpec.describe CurrencyConverter do
|
|
6
|
+
describe "VERSION" do
|
|
7
|
+
it "has a version number" do
|
|
8
|
+
expect(CurrencyConverter::VERSION).not_to be nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it "has correct version format" do
|
|
12
|
+
expect(CurrencyConverter::VERSION).to match(/\d+\.\d+\.\d+/)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/spec/spec_helper.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: currency-converter
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shobhit Jain
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-01-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Fetches real-time currency exchange rates and allows currency conversion.
|
|
14
14
|
email:
|
|
@@ -43,11 +43,13 @@ files:
|
|
|
43
43
|
- lib/currency_converter/converter.rb
|
|
44
44
|
- lib/currency_converter/errors.rb
|
|
45
45
|
- lib/currency_converter/version.rb
|
|
46
|
+
- manual_integration_test.rb
|
|
46
47
|
- sig/currency_converter.rbs
|
|
47
48
|
- sig/currency_converter/converter.rbs
|
|
48
49
|
- spec/currency_converter/api_client_spec.rb
|
|
49
50
|
- spec/currency_converter/cache_spec.rb
|
|
50
51
|
- spec/currency_converter/converter_spec.rb
|
|
52
|
+
- spec/currency_converter/version_spec.rb
|
|
51
53
|
- spec/spec_helper.rb
|
|
52
54
|
homepage: https://github.com/shobhits7/currency_converter
|
|
53
55
|
licenses:
|