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: Documentation:
Enabled: false Enabled: false
Metrics/BlockLength: Metrics/BlockLength:
Enabled: false ExcludedMethods: ['describe', 'helpers']
Metrics/LineLength: Metrics/MethodLength:
Enabled: false Max: 11

View File

@ -8,10 +8,10 @@ Rates are updated around 4PM CET every working day.
## Usage ## Usage
Get the latest foreign exchange rates. Get the current foreign exchange rates.
```http ```http
GET /latest GET /current
``` ```
Get historical rates for any day since 1999. 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. Rates quote against the Euro by default. Quote against a different currency.
```http ```http
GET /latest?from=USD GET /current?from=USD
``` ```
Request specific exchange rates. Request specific exchange rates.
```http ```http
GET /latest?to=GBP GET /current?to=GBP
``` ```
Change the amount requested. Change the converted amount.
```http ```http
GET /latest?amount=100 GET /current?amount=100
``` ```
Finally, use all the above together. Finally, use all the above together.
```http ```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 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)) alert("£1 = $" + rate.toFixed(4))
} }
fetch('https://yourdomain.com/latest') fetch('https://yourdomain.com/current')
.then((resp) => resp.json()) .then((resp) => resp.json())
.then((data) => fx.rates = data.rates) .then((data) => fx.rates = data.rates)
.then(demo) .then(demo)

View File

@ -4,4 +4,5 @@ require 'pg'
require 'sequel' require 'sequel'
Sequel.single_threaded = true 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)) .order(Sequel.desc(:date))
.limit(1)) .limit(1))
end 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
end end

View File

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

View File

@ -1,61 +1,4 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'currency' require 'quote/end_of_day'
require 'quote/interval'
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

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

View File

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

View File

@ -7,43 +7,43 @@
<title>Frankfurter</title> <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/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="//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="stylesheet" href="/stylesheets/application.css">
<link rel="shortcut icon" href="/images/frankfurter-icon.png"> <link rel="shortcut icon" href="/images/favicon.ico">
</head> </head>
<body> <body>
<nav class="navbar navbar-expand"> <nav class="navbar navbar-expand">
<div class="container"> <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"> <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> <a class="nav-link" href="https://www.ecb.europa.eu/stats/exchange/eurofxref/html/index.en.html" target="_blank">Data sets</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="https://status.frankfurter.app/" target="_blank">Status</a> <a class="nav-link" href="https://status.frankfurter.app/" target="_blank">Status</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="https://github.com/hakanensari/frankfurter" target="_blank">Source</a>
</li>
</ul> </ul>
</div> </div>
</nav> </nav>
<section class="hero section"> <section class="hero section">
<div class="container"> <div class="container">
<image class="logo" src="/images/frankfurter.png" alt="">
<h1 class="display-4">Frankfurter</h1> <h1 class="display-4">Frankfurter</h1>
<p class="lead"> <p class="lead">
Foreign exchange rates and currency conversion API Foreign exchange rates and currency conversion API
</p> </p>
<img alt="" class="logo" src="/images/frankfurter.png">
</div> </div>
</section> </section>
<section class="introduction section"> <section class="introduction section">
<div class="container"> <div class="container">
<p> <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>
<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> </p>
</div> </div>
</section> </section>
@ -58,7 +58,11 @@
<p> <p>
Get historical rates for any day since 1999. Get historical rates for any day since 1999.
</p> </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> <p>
Rates are quoted against the Euro by default. Quote against a different Rates are quoted against the Euro by default. Quote against a different
currency by setting the <strong>from</strong> parameter in your request. currency by setting the <strong>from</strong> parameter in your request.
@ -69,9 +73,9 @@
</p> </p>
<pre><code class="http hljs small">GET /current?to=USD,GBP <i>HTTP/1.1</i></code></pre> <pre><code class="http hljs small">GET /current?to=USD,GBP <i>HTTP/1.1</i></code></pre>
<p> <p>
Convert a specific value. Convert a specific value using <strong>amount</strong>.
</p> </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> </div>
</section> </section>
<section class="best-practices 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> <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> </div>
</section> </section>
<script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js"></script> <script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js"></script>
<script src="/javascripts/application.js"></script> <script type="text/javascript" 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 async type="text/javascript" src="//cdn.carbonads.com/carbon.js?serve=CK7D45QM&placement=frankfurterapp" id="_carbonads_js"></script>
</body> </body>
</html> </html>

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ describe Query do
query.amount.must_equal 100.0 query.amount.must_equal 100.0
end end
it 'defaults amount to nothin' do it 'defaults amount to nothing' do
query = Query.new query = Query.new
query.amount.must_be_nil query.amount.must_be_nil
end end
@ -24,7 +24,7 @@ describe Query do
query.base.must_be_nil query.base.must_be_nil
end end
it 'aliases base as from' do it 'aliases base with from' do
query = Query.new(from: 'USD') query = Query.new(from: 'USD')
query.base.must_equal 'USD' query.base.must_equal 'USD'
end end
@ -34,7 +34,7 @@ describe Query do
query.symbols.must_equal %w[USD GBP] query.symbols.must_equal %w[USD GBP]
end end
it 'aliases symbols to to' do it 'aliases symbols with to' do
query = Query.new(to: 'USD') query = Query.new(to: 'USD')
query.symbols.must_equal ['USD'] query.symbols.must_equal ['USD']
end end
@ -45,12 +45,15 @@ describe Query do
end end
it 'returns given date' do it 'returns given date' do
query = Query.new(date: '2014-01-01') date = '2014-01-01'
query.date.must_equal '2014-01-01' query = Query.new(date: date)
query.date.must_equal Date.parse(date)
end end
it 'defaults date to nothing' do it 'returns given date interval' do
query = Query.new start_date = '2014-01-01'
query.date.must_be_nil 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
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? json['rates'].wont_be :empty?
end end
it 'returns a cache control header' do it 'returns an ETag' 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
%w[/current /2012-11-20].each do |path| %w[/current /2012-11-20].each do |path|
get path get path
headers['Last-Modified'].wont_be_nil headers['ETag'].wont_be_nil
end end
end end
@ -88,8 +81,8 @@ describe 'the server' do
it 'returns rates for a given period' do it 'returns rates for a given period' do
get '/2010-01-01..2010-12-31' get '/2010-01-01..2010-12-31'
json['start_date'].must_equal '2010-01-01' json['start_date'].wont_be :empty?
json['end_date'].must_equal '2010-12-31' json['end_date'].wont_be :empty?
json['rates'].wont_be empty json['rates'].wont_be :empty?
end end
end end