Implement time series

... along with some minor miscellaneous refactoring

This finally completes fixerAPI/fixer#22
This commit is contained in:
Hakan Ensari 2018-07-05 20:19:37 +01:00
parent fcc5f8504f
commit a96e56808e
26 changed files with 618 additions and 220 deletions

View File

@ -3,6 +3,6 @@ AllCops:
Documentation:
Enabled: false
Metrics/BlockLength:
Enabled: false
Metrics/LineLength:
Enabled: false
ExcludedMethods: ['describe', 'helpers']
Metrics/MethodLength:
Max: 11

View File

@ -8,10 +8,10 @@ Rates are updated around 4PM CET every working day.
## Usage
Get the latest foreign exchange rates.
Get the current foreign exchange rates.
```http
GET /latest
GET /current
```
Get historical rates for any day since 1999.
@ -23,25 +23,25 @@ GET /2000-01-03
Rates quote against the Euro by default. Quote against a different currency.
```http
GET /latest?from=USD
GET /current?from=USD
```
Request specific exchange rates.
```http
GET /latest?to=GBP
GET /current?to=GBP
```
Change the amount requested.
Change the converted amount.
```http
GET /latest?amount=100
GET /current?amount=100
```
Finally, use all the above together.
```http
GET /latest?from=EUR&to=GBP&amount=100
GET /current?from=EUR&to=GBP&amount=100
```
The primary use case is client side. For instance, with [money.js](https://openexchangerates.github.io/money.js/) in the browser
@ -52,7 +52,7 @@ let demo = () => {
alert("£1 = $" + rate.toFixed(4))
}
fetch('https://yourdomain.com/latest')
fetch('https://yourdomain.com/current')
.then((resp) => resp.json())
.then((data) => fx.rates = data.rates)
.then(demo)

View File

@ -4,4 +4,5 @@ require 'pg'
require 'sequel'
Sequel.single_threaded = true
Sequel.connect(ENV['DATABASE_URL'] || "postgres://localhost/frankfurter_#{App.env}")
Sequel.connect(ENV['DATABASE_URL'] ||
"postgres://localhost/frankfurter_#{App.env}")

View File

@ -7,5 +7,24 @@ class Currency < Sequel::Model
.order(Sequel.desc(:date))
.limit(1))
end
def between(date_interval)
query = where(date: date_interval).order(:date)
length = date_interval.last - date_interval.first
if length > 365
query.sampled('month')
elsif length > 90
query.sampled('week')
else
query
end
end
def sampled(precision)
sampled_date = Sequel.lit("date_trunc('#{precision}', date)")
select(:iso_code).select_append { avg(rate).as(rate) }
.select_append(sampled_date.as(:date))
.group(:iso_code, sampled_date)
end
end
end

View File

@ -6,19 +6,24 @@ class Query
end
def amount
@params[:amount].to_f if @params[:amount] # rubocop:disable Style/SafeNavigation
return unless @params[:amount]
@params[:amount].to_f
end
def base
@params.values_at(:base, :from).compact.first&.upcase
@params.values_at(:from, :base).compact.first&.upcase
end
def symbols
@params.values_at(:symbols, :to).compact.first&.split(',')
@params.values_at(:to, :symbols).compact.first&.split(',')
end
def date
@params[:date]
if @params[:date]
Date.parse(@params[:date])
else
(Date.parse(@params[:start_date])..Date.parse(@params[:end_date]))
end
end
def to_h

View File

@ -1,61 +1,4 @@
# frozen_string_literal: true
require 'currency'
class Quote
DEFAULT_BASE = 'EUR'
def initialize(amount: 1.0,
base: DEFAULT_BASE,
date: Date.today.to_s,
symbols: nil)
@amount = amount
@base = base
@date = date
@symbols = symbols
end
def to_h
{ base: @base, date: date, rates: calculate_rates }
end
def date
currencies.first[:date].to_s
end
private
def calculate_rates # rubocop:disable Metrics/AbcSize
rates = currencies.each_with_object({}) do |currency, hsh|
hsh[currency[:iso_code]] = currency[:rate]
end
return rates if @base == DEFAULT_BASE && @amount == 1.0
if @symbols.nil? || @symbols.include?(DEFAULT_BASE) || @base == DEFAULT_BASE
rates.update(DEFAULT_BASE => 1.0)
end
divisor = rates.delete(@base)
rates.sort.map! { |ic, rate| [ic, round(@amount * rate / divisor)] }.to_h
end
def currencies
@currencies ||= begin
scope = Currency.latest(@date)
scope = scope.where(iso_code: @symbols + [@base]) if @symbols
scope.order(:iso_code).naked
end
end
# To paraphrase Wikipedia, most currency pairs are quoted to four decimal
# places. An exception to this is exchange rates with a value of less than
# 1.000, which are quoted to five or six decimal places. Exchange rates
# greater than around 20 are usually quoted to three decimal places and
# exchange rates greater than 80 are quoted to two decimal places.
# Currencies over 5000 are usually quoted with no decimal places.
def round(rate)
Float(format('%.5g', rate))
end
end
require 'quote/end_of_day'
require 'quote/interval'

82
lib/quote/base.rb Normal file
View File

@ -0,0 +1,82 @@
# frozen_string_literal: true
require 'roundable'
module Quote
class Base
include Roundable
DEFAULT_BASE = 'EUR'
attr_reader :amount, :base, :date, :symbols, :result
def initialize(date:, amount: 1.0, base: 'EUR', symbols: nil)
@date = date
@amount = amount
@base = base
@symbols = symbols
@result = {}
end
def perform
return false if result.frozen?
prepare_rates
rebase_rates if must_rebase?
result.freeze
true
end
def must_rebase?
base != 'EUR'
end
def formatted
raise NotImplementedError
end
def not_found?
result.empty?
end
def cache_key
raise NotImplementedError
end
private
def data
@data ||= fetch_data
end
def fetch_data
raise NotImplementedError
end
def prepare_rates
data.each_with_object(result) do |currency, result|
date = currency[:date].to_date.to_s
result[date] ||= {}
result[date][currency[:iso_code]] = round(amount * currency[:rate])
end
end
def rebase_rates
result.each do |date, rates|
add_euro(rates)
divisor = rates.delete(base)
result[date] = rates.sort
.map! do |iso_code, rate|
[iso_code, round(amount * rate / divisor)]
end
.to_h
end
end
def add_euro(rates)
rates['EUR'] = amount if symbols.nil? || symbols.include?('EUR')
end
end
end

26
lib/quote/end_of_day.rb Normal file
View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'currency'
require 'quote/base'
module Quote
class EndOfDay < Base
def formatted
{ base: base, date: result.keys.first, rates: result.values.first }
end
def cache_key
return if not_found?
Digest::MD5.hexdigest(result.keys.first)
end
private
def fetch_data
scope = Currency.latest(date)
scope = scope.where(iso_code: symbols + [base]) if symbols
scope.naked
end
end
end

29
lib/quote/interval.rb Normal file
View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
require 'currency'
require 'quote/base'
module Quote
class Interval < Base
def formatted
{ base: base,
start_date: result.keys.first,
end_date: result.keys.last,
rates: result }
end
def cache_key
return if not_found?
Digest::MD5.hexdigest(result.keys.last)
end
private
def fetch_data
scope = Currency.between(date)
scope = scope.where(iso_code: symbols + [base]) if symbols
scope.naked
end
end
end

23
lib/roundable.rb Normal file
View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
module Roundable
# To paraphrase Wikipedia, most currency pairs are quoted to four decimal
# places. An exception to this is exchange rates with a value of less than
# 1.000, which are quoted to five or six decimal places. Exchange rates
# greater than around 20 are usually quoted to three decimal places and
# exchange rates greater than 80 are quoted to two decimal places.
# Currencies over 5000 are usually quoted with no decimal places.
def round(value)
if value > 5000
value.round
elsif value > 80
Float(format('%.2f', value))
elsif value > 20
Float(format('%.3f', value))
elsif value > 1
Float(format('%.4f', value))
else
Float(format('%.5f', value))
end
end
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -2,25 +2,44 @@ body {
font-family: 'Open Sans', sans-serif;
}
.navbar .navbar-nav .nav-link {
strong {
background-color: #f8f9fa;
}
.navbar {
background-color: #337cf6;
font-size: .875rem;
padding-bottom: 0;
padding-top: 0;
}
.navbar .nav-link {
color: #fff;
}
.navbar .nav-link:hover {
color: #f8f9fa;
}
.container {
max-width: 720px;
max-width: 600px;
}
.section {
padding-top: 1em;
}
.hero {
margin: 2em 0;
}
.hero .container {
text-align: center;
}
.hero .logo {
padding-bottom: 1em;
width: 120px;
width: 100px;
}
.footer .container {
@ -30,12 +49,10 @@ body {
.hljs {
background-color: #f8f9fa;
border: 2px solid #f8f9fa;
padding: 1em;
}
.hljs:hover {
border-bottom-color: #dee2e6;
cursor: pointer;
}
@ -44,41 +61,49 @@ body {
font-style: normal;
}
#carbonads {
display: none;
@media screen and (max-width: 767px) {
.hero .logo {
width: 75px;
}
/*
@media screen and (max-width: 767px) {
#carbonads {
display: none;
}
}
@media screen and (min-width: 768px) {
#carbonads {
background-color: #f0f0f0;
background-color: #f8f9fa;
position: absolute;
right: 24px;
top: 36px;
top: 24px;
width: 150px;
}
.carbon-text {
color: #495057;
}
.carbon-img,
.carbon-poweredby,
.carbon-text {
display: block;
margin: 10px;
}
.carbon-text,
.carbon-poweredby {
line-height: 1.3;
text-decoration: none;
}
.carbon-text {
font-size: 13px;
}
.carbon-poweredby {
color: #6c757d;
font-size: 11px;
color: #999;
}
}
*/

View File

@ -15,6 +15,10 @@ use Rack::Cors do
end
configure :development do
require 'rack-livereload'
use Rack::LiveReload
set :show_exceptions, :after_handler
end
@ -27,13 +31,30 @@ configure :test do
end
helpers do
def quote
@quote ||= begin
query = Query.new(params)
Quote.new(query.to_h)
def end_of_day_quote
@end_of_day_quote ||= begin
quote = Quote::EndOfDay.new(query)
quote.perform
halt 404 if quote.not_found?
quote
end
end
def interval_quote
@interval_quote ||= begin
quote = Quote::Interval.new(query)
quote.perform
halt 404 if quote.not_found?
quote
end
end
def query
Query.new(params).to_h
end
def json(data)
json = Oj.dump(data, mode: :compat)
callback = params.delete('callback')
@ -52,28 +73,25 @@ options '*' do
200
end
get '*' do
cache_control :public
pass
end
get '/' do
erb :index
end
get '/(?:latest|current)', mustermann_opts: { type: :regexp } do
last_modified quote.date
json quote.to_h
params[:date] = Date.today.to_s
etag end_of_day_quote.cache_key
json end_of_day_quote.formatted
end
get '/(?<date>\d{4}-\d{2}-\d{2})', mustermann_opts: { type: :regexp } do
last_modified quote.date
json quote.to_h
etag end_of_day_quote.cache_key
json end_of_day_quote.formatted
end
get '/(?<start_date>\d{4}-\d{2}-\d{2})\.\.(?<end_date>\d{4}-\d{2}-\d{2})', mustermann_opts: { type: :regexp } do
last_modified quote.end_date
json quote.to_h
get '/(?<start_date>\d{4}-\d{2}-\d{2})\.\.(?<end_date>\d{4}-\d{2}-\d{2})',
mustermann_opts: { type: :regexp } do
etag interval_quote.cache_key
json interval_quote.formatted
end
not_found do

View File

@ -7,43 +7,43 @@
<title>Frankfurter</title>
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/styles/github.min.css">
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.1.1/css/bootstrap.min.css">
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Open+Sans:300,400">
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Open+Sans:300,400,600">
<link rel="stylesheet" href="/stylesheets/application.css">
<link rel="shortcut icon" href="/images/frankfurter-icon.png">
<link rel="shortcut icon" href="/images/favicon.ico">
</head>
<body>
<nav class="navbar navbar-expand">
<div class="container">
<ul class="navbar-nav mx-sm-auto">
<ul class="navbar-nav mx-auto">
<li class="nav-item">
<a class="nav-link" href="https://github.com/hakanensari/frankfurter" target="_blank">Source code</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://www.ecb.europa.eu/stats/exchange/eurofxref/html/index.en.html" target="_blank">Data sets</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://status.frankfurter.app/" target="_blank">Status</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://github.com/hakanensari/frankfurter" target="_blank">Source</a>
</li>
</ul>
</div>
</nav>
<section class="hero section">
<div class="container">
<image class="logo" src="/images/frankfurter.png" alt="">
<h1 class="display-4">Frankfurter</h1>
<p class="lead">
Foreign exchange rates and currency conversion API
</p>
<img alt="" class="logo" src="/images/frankfurter.png">
</div>
</section>
<section class="introduction section">
<div class="container">
<p>
Frankfurter is a lightweight API for querying current and historical forex rates published by the European Central Bank.
Frankfurter is an open-source API for current and historical forex rates published by the European Central Bank.
</p>
<p>
Rates update around 4PM CET on every working day. Historical data goes back to early 1999.
The API updates rates around 4PM CET every working day. Historical data goes back to early 1999.
</p>
</div>
</section>
@ -58,7 +58,11 @@
<p>
Get historical rates for any day since 1999.
</p>
<pre><code class="http hljs small">GET /1999-12-31 <i>HTTP/1.1</i></code></pre>
<pre><code class="http hljs small">GET /2000-01-01 <i>HTTP/1.1</i></code></pre>
<p>
Get historical exchange rates for a given time period.
</p>
<pre><code class="http hljs small">GET /2000-01-01..2000-12-31 <i>HTTP/1.1</i></code></pre>
<p>
Rates are quoted against the Euro by default. Quote against a different
currency by setting the <strong>from</strong> parameter in your request.
@ -69,9 +73,9 @@
</p>
<pre><code class="http hljs small">GET /current?to=USD,GBP <i>HTTP/1.1</i></code></pre>
<p>
Convert a specific value.
Convert a specific value using <strong>amount</strong>.
</p>
<pre><code class="http hljs small">GET /current?amount=1000&from=GBP&to=EUR <i>HTTP/1.1</i></code></pre>
<pre><code class="http hljs small">GET /current?amount=1000 <i>HTTP/1.1</i></code></pre>
</div>
</section>
<section class="best-practices section">
@ -110,9 +114,9 @@ fetch('/current?from=GBP&to=USD')
<iframe src="https://ghbtns.com/github-btn.html?user=hakanensari&repo=frankfurter&type=star&count=true" frameborder="0" scrolling="0" width="120px" height="20px"></iframe>
</div>
</section>
<script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js"></script>
<script src="/javascripts/application.js"></script>
<script async type="text/javascript" src="//cdn.carbonads.com/carbon.js?zoneid=1673&serve=C6AILKT&placement=fixerio" id="_carbonads_js"></script>
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js"></script>
<script type="text/javascript" src="/javascripts/application.js"></script>
<script async type="text/javascript" src="//cdn.carbonads.com/carbon.js?serve=CK7D45QM&placement=frankfurterapp" id="_carbonads_js"></script>
</body>
</html>

View File

@ -4,37 +4,52 @@ require_relative 'helper'
require 'currency'
describe Currency do
around do |test|
Currency.db.transaction do
test.call
raise Sequel::Rollback
end
end
before do
Currency.dataset.delete
@earlier = [
Currency.create(iso_code: 'EUR', rate: 1, date: '2014-01-01'),
Currency.create(iso_code: 'USD', rate: 2, date: '2014-01-01')
]
@later = [
Currency.create(iso_code: 'EUR', rate: 1, date: '2015-01-01'),
Currency.create(iso_code: 'USD', rate: 2, date: '2015-01-01')
]
end
it 'returns latest rates' do
data = Currency.latest.to_a
data.sample.date.must_equal @later.sample.date
describe '.current' do
it 'returns current rates' do
date = Currency.order(Sequel.desc(:date)).first.date
rates = Currency.latest
rates.first.date.must_equal date
end
it 'returns latest rates before given date' do
data = Currency.latest(@later.sample.date - 1).to_a
data.sample.date.must_equal @earlier.sample.date
date = Date.parse('2010-01-01')
rates = Currency.latest(date)
rates.first.date.must_be :<=, date
end
it 'returns nothing if there are no rates before given date' do
data = Currency.latest(@earlier.sample.date - 1).to_a
data.must_be_empty
rates = Currency.latest(Date.parse('1998-01-01'))
rates.must_be_empty
end
end
describe '.between' do
it 'returns rates between given dates' do
start_date = Date.parse('2010-01-01')
end_date = Date.parse('2010-01-31')
dates = Currency.between((start_date..end_date)).map(:date).uniq.sort
dates.first.must_be :>=, start_date
dates.last.must_be :<=, end_date
end
it 'returns nothing if there are no rates between given dates' do
date_interval = (Date.parse('1998-01-01')..Date.parse('1998-01-31'))
Currency.between(date_interval).must_be_empty
end
it 'returns all rates up to 90 days' do
date_interval = (Date.parse('2010-01-01')..Date.parse('2010-03-01'))
Currency.between(date_interval).map(:date).uniq.count.must_be :>, 30
end
it 'samples weeks over 90 days and below 366 days' do
date_interval = (Date.parse('2010-01-01')..Date.parse('2010-12-31'))
Currency.between(date_interval).map(:date).uniq.count.must_be :<=, 52
end
it 'samples months over 365 days' do
date_interval = (Date.parse('2001-01-01')..Date.parse('2010-12-31'))
Currency.between(date_interval).map(:date).uniq.count.must_be :<=, 120
end
end
end

View File

@ -25,7 +25,7 @@ describe 'the API' do
it 'will not process a date before 2000' do
get '/1999-01-01'
last_response.must_be :unprocessable?
last_response.must_be :not_found?
end
it 'will not process an invalid base' do

View File

@ -1,5 +1,7 @@
# frozen_string_literal: true
ENV['APP_ENV'] ||= 'test'
require_relative '../config/environment'
require 'minitest/autorun'

View File

@ -9,7 +9,7 @@ describe Query do
query.amount.must_equal 100.0
end
it 'defaults amount to nothin' do
it 'defaults amount to nothing' do
query = Query.new
query.amount.must_be_nil
end
@ -24,7 +24,7 @@ describe Query do
query.base.must_be_nil
end
it 'aliases base as from' do
it 'aliases base with from' do
query = Query.new(from: 'USD')
query.base.must_equal 'USD'
end
@ -34,7 +34,7 @@ describe Query do
query.symbols.must_equal %w[USD GBP]
end
it 'aliases symbols to to' do
it 'aliases symbols with to' do
query = Query.new(to: 'USD')
query.symbols.must_equal ['USD']
end
@ -45,12 +45,15 @@ describe Query do
end
it 'returns given date' do
query = Query.new(date: '2014-01-01')
query.date.must_equal '2014-01-01'
date = '2014-01-01'
query = Query.new(date: date)
query.date.must_equal Date.parse(date)
end
it 'defaults date to nothing' do
query = Query.new
query.date.must_be_nil
it 'returns given date interval' do
start_date = '2014-01-01'
end_date = '2014-12-31'
query = Query.new(start_date: start_date, end_date: end_date)
query.date.must_equal((Date.parse(start_date)..Date.parse(end_date)))
end
end

55
spec/quote/base_spec.rb Normal file
View File

@ -0,0 +1,55 @@
# frozen_string_literal: true
require_relative '../helper'
require 'quote/base'
module Quote
describe Base do
let(:klass) do
Class.new(Base)
end
let(:quote) do
klass.new(date: Date.today)
end
it 'requires data' do
-> { quote.perform }.must_raise NotImplementedError
end
it 'does not know how to format result' do
-> { quote.formatted }.must_raise NotImplementedError
end
it 'does not know how to generate a cache key' do
-> { quote.cache_key }.must_raise NotImplementedError
end
it 'defaults base to Euro' do
quote.base.must_equal 'EUR'
end
it 'defaults amount to 1' do
quote.amount.must_equal 1
end
describe 'when given data' do
let(:klass) do
Class.new(Base) do
def fetch_data
[]
end
end
end
it 'performs' do
assert quote.perform
end
it 'performs only once' do
quote.perform
refute quote.perform
end
end
end
end

View File

@ -0,0 +1,83 @@
# frozen_string_literal: true
require_relative '../helper'
require 'quote/end_of_day'
module Quote
describe EndOfDay do
let(:date) do
Date.parse('2010-10-10')
end
let(:quote) do
EndOfDay.new(date: date)
end
before do
quote.perform
end
it 'returns rates' do
quote.formatted[:rates].wont_be :empty?
end
it 'quotes given date' do
Date.parse(quote.formatted[:date]).must_be :<=, date
end
it 'quotes against the Euro' do
quote.formatted[:rates].keys.wont_include 'EUR'
end
it 'sorts rates' do
rates = quote.formatted[:rates]
rates.keys.must_equal rates.keys.sort
end
it 'has a cache key' do
quote.cache_key.wont_be :empty?
end
describe 'given a new base' do
let(:quote) do
EndOfDay.new(date: date, base: 'USD')
end
it 'quotes against that base' do
quote.formatted[:rates].keys.wont_include 'USD'
end
it 'sorts rates' do
rates = quote.formatted[:rates]
rates.keys.must_equal rates.keys.sort
end
end
describe 'given symbols' do
let(:quote) do
EndOfDay.new(date: date, symbols: %w[USD GBP JPY])
end
it 'quotes only for those symbols' do
rates = quote.formatted[:rates]
rates.keys.must_include 'USD'
rates.keys.wont_include 'CAD'
end
it 'sorts rates' do
rates = quote.formatted[:rates]
rates.keys.must_equal rates.keys.sort
end
end
describe 'when given an amount' do
let(:quote) do
EndOfDay.new(date: date, amount: 100)
end
it 'calculates quotes for that amount' do
quote.formatted[:rates]['USD'].must_be :>, 10
end
end
end
end

View File

@ -0,0 +1,94 @@
# frozen_string_literal: true
require_relative '../helper'
require 'quote/interval'
module Quote
describe Interval do
let(:date_interval) do
(Date.parse('2010-01-01')..Date.parse('2010-12-31'))
end
let(:quote) do
Interval.new(date: date_interval)
end
before do
quote.perform
end
it 'returns rates' do
quote.formatted[:rates].wont_be :empty?
end
it 'quotes given date interval' do
Date.parse(quote.formatted[:start_date]).must_be :>=, date_interval.first
Date.parse(quote.formatted[:end_date]).must_be :<=, date_interval.last
end
it 'quotes against the Euro' do
quote.formatted[:rates].each_value do |rates|
rates.keys.wont_include 'EUR'
end
end
it 'sorts rates' do
quote.formatted[:rates].each_value do |rates|
rates.keys.must_equal rates.keys.sort
end
end
it 'has a cache key' do
quote.cache_key.wont_be :empty?
end
describe 'given a new base' do
let(:quote) do
Interval.new(date: date_interval, base: 'USD')
end
it 'quotes against that base' do
quote.formatted[:rates].each_value do |rates|
rates.keys.wont_include 'USD'
end
end
it 'sorts rates' do
quote.formatted[:rates].each_value do |rates|
rates.keys.must_equal rates.keys.sort
end
end
end
describe 'given symbols' do
let(:quote) do
Interval.new(date: date_interval, symbols: %w[USD GBP JPY])
end
it 'quotes only for those symbols' do
quote.formatted[:rates].each_value do |rates|
rates.keys.must_include 'USD'
rates.keys.wont_include 'CAD'
end
end
it 'sorts rates' do
quote.formatted[:rates].each_value do |rates|
rates.keys.must_equal rates.keys.sort
end
end
end
describe 'when given an amount' do
let(:quote) do
Interval.new(date: date_interval, amount: 100)
end
it 'calculates quotes for that amount' do
quote.formatted[:rates].each_value do |rates|
rates['USD'].must_be :>, 10
end
end
end
end
end

View File

@ -1,53 +0,0 @@
# frozen_string_literal: true
require_relative 'helper'
require 'quote'
describe Quote do
describe 'by default' do
it 'quotes against the Euro' do
quote = Quote.new
rates = quote.to_h[:rates]
rates.keys.wont_include 'EUR'
end
end
describe 'when given a base' do
it 'quotes against that base' do
quote = Quote.new(base: 'USD')
rates = quote.to_h[:rates]
rates.keys.wont_include 'USD'
end
it 'sorts rates' do
quote = Quote.new(base: 'USD')
rates = quote.to_h[:rates]
rates.keys.must_equal rates.keys.sort
end
end
describe 'when given symbols' do
it 'quotes rates only for given symbols' do
quote = Quote.new(symbols: ['USD'])
rates = quote.to_h[:rates]
rates.keys.must_include 'USD'
rates.keys.wont_include 'GBP'
end
end
describe 'when given an amount' do
it 'quotes for that amount' do
quote = Quote.new(amount: 100)
rates = quote.to_h[:rates]
rates['USD'].must_be :>, 10
end
end
describe 'when given an amount and symbols' do
it 'quotes for that amount' do
quote = Quote.new(amount: 100, symbols: ['USD'])
rates = quote.to_h[:rates]
rates['USD'].must_be :>, 10
end
end
end

31
spec/roundable_spec.rb Normal file
View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
require_relative 'helper'
require 'roundable'
describe Roundable do
include Roundable
it 'rounds values over 5,000 to zero decimal places' do
round(5000.123456).must_equal 5000.0
end
it 'rounds values over 80 and below 5,000 to two decimal places' do
round(80.123456).must_equal 80.12
round(4999.123456).must_equal 4999.12
end
it 'rounds values over 20 and below 80 to three decimal places' do
round(79.123456).must_equal 79.123
round(20.123456).must_equal 20.123
end
it 'rounds values over 1 and below 20 to four decimal places' do
round(19.123456).must_equal 19.1235
round(1.123456).must_equal 1.1235
end
it 'rounds values below 1 to five decimal places' do
round(0.123456).must_equal 0.12346
end
end

View File

@ -49,17 +49,10 @@ describe 'the server' do
json['rates'].wont_be :empty?
end
it 'returns a cache control header' do
%w[/ /current /2012-11-20].each do |path|
get path
headers['Cache-Control'].wont_be_nil
end
end
it 'returns a last modified header' do
it 'returns an ETag' do
%w[/current /2012-11-20].each do |path|
get path
headers['Last-Modified'].wont_be_nil
headers['ETag'].wont_be_nil
end
end
@ -88,8 +81,8 @@ describe 'the server' do
it 'returns rates for a given period' do
get '/2010-01-01..2010-12-31'
json['start_date'].must_equal '2010-01-01'
json['end_date'].must_equal '2010-12-31'
json['rates'].wont_be empty
json['start_date'].wont_be :empty?
json['end_date'].wont_be :empty?
json['rates'].wont_be :empty?
end
end