Skip to content

Commit e0dfd18

Browse files
committed
tests: replace real currencyrate endpoints with unit tests and http error code checks
specifically coingecko was constantly hitting API rate limits causing our tests to fail so lets unit tests all endpoints with a snapshot of real responses and only allow for http errors in integration tests If the actual API's change, break, or vanish we have to notice it in the logs Changelog-None
1 parent bfe5dd4 commit e0dfd18

2 files changed

Lines changed: 240 additions & 183 deletions

File tree

plugins/currencyrate-plugin/src/oracle.rs

Lines changed: 212 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -69,58 +69,26 @@ impl Source {
6969
let currency = currency.to_uppercase();
7070
let url = self.url(&currency_lc, &currency);
7171

72-
let resp: Value = client
72+
let response = client
7373
.get(&url)
7474
.send()
7575
.await
76-
.map_err(|e| anyhow!("Failed to request url {url} caused by: {:?}", e.source()))?
76+
.map_err(|e| anyhow!("Request failed {url}: {:?}", e.source()))?;
77+
78+
let status = response.status();
79+
80+
let resp: Value = response
7781
.json()
7882
.await
79-
.map_err(|e| {
80-
anyhow!(
81-
"Failed to decode response body from {url}, caused by: {:?}",
82-
e.source()
83-
)
84-
})?;
85-
86-
let reply_members = self.reply_members(&currency_lc, &currency);
83+
.map_err(|e| anyhow!("Failed to parse JSON from {url}: {:?}", e.source()))?;
8784

88-
let mut current = &mut resp.clone();
89-
for member in reply_members {
90-
if let Ok(pos) = member.parse::<usize>() {
91-
current = current.get_mut(pos).ok_or(anyhow!(
92-
"Positional member `{}` not found in {} response: {}",
93-
member,
94-
self.name(),
95-
resp
96-
))?;
97-
} else {
98-
current = current.get_mut(&member).ok_or(anyhow!(
99-
"Member `{}` not found in {} response: {}",
100-
member,
101-
self.name(),
102-
resp
103-
))?;
104-
}
85+
if !status.is_success() {
86+
return Err(anyhow!("HTTP error {status} from {url}: body={resp}"));
10587
}
106-
let price = match current {
107-
Value::Number(number) => number
108-
.as_f64()
109-
.ok_or(anyhow!("Json number price could not be converted to float"))?,
110-
Value::String(string) => string
111-
.parse::<f64>()
112-
.map_err(|e| anyhow!("Price string could not be converted to float: {e}"))?,
113-
_ => return Err(anyhow!("Price is invalid json type")),
114-
};
11588

116-
if price == 0.0 {
117-
log::warn!("{} returned 0.0 as price for {}", self.name, currency);
118-
return Err(anyhow!(
119-
"{} returned 0.0 as price for {}",
120-
self.name,
121-
currency
122-
));
123-
}
89+
let reply_members = self.reply_members(&currency_lc, &currency);
90+
91+
let price = extract_price_from_response(&resp, &reply_members, self.name(), &currency)?;
12492

12593
log::info!(
12694
"Fetched price in {}ms from {}: {:.2} {currency}/BTC",
@@ -133,6 +101,40 @@ impl Source {
133101
}
134102
}
135103

104+
fn extract_price_from_response(
105+
resp: &Value,
106+
reply_members: &[String],
107+
name: &str,
108+
currency: &str,
109+
) -> Result<f64, anyhow::Error> {
110+
let mut current = &mut resp.clone();
111+
for member in reply_members {
112+
if let Ok(pos) = member.parse::<usize>() {
113+
current = current.get_mut(pos).ok_or(anyhow!(
114+
"Positional member `{member}` not found in {name} response: {resp}"
115+
))?;
116+
} else {
117+
current = current.get_mut(member).ok_or(anyhow!(
118+
"Member `{member}` not found in {name} response: {resp}"
119+
))?;
120+
}
121+
}
122+
let price = match current {
123+
Value::Number(number) => number
124+
.as_f64()
125+
.ok_or(anyhow!("Json number price could not be converted to float"))?,
126+
Value::String(string) => string
127+
.parse::<f64>()
128+
.map_err(|e| anyhow!("Price string could not be converted to float: {e}"))?,
129+
_ => return Err(anyhow!("Price is invalid json type")),
130+
};
131+
132+
if price == 0.0 {
133+
log::warn!("{name} returned 0.0 as price for {currency}");
134+
return Err(anyhow!("{name} returned 0.0 as price for {currency}"));
135+
}
136+
Ok(price)
137+
}
136138
struct SourceHealth {
137139
source: Source,
138140
failures: u32,
@@ -535,12 +537,7 @@ impl BtcPriceOracle {
535537

536538
// Give a helpful error if the source name is unknown entirely
537539
if !inner.sources.contains_key(source_name) {
538-
let available = inner
539-
.sources
540-
.keys()
541-
.cloned()
542-
.collect::<Vec<_>>()
543-
.join(", ");
540+
let available = inner.sources.keys().cloned().collect::<Vec<_>>().join(", ");
544541
return Err(anyhow!(
545542
"Unknown source `{source_name}`. Available sources: {available}"
546543
));
@@ -551,13 +548,12 @@ impl BtcPriceOracle {
551548
.get(currency)
552549
.ok_or_else(|| anyhow!("No rates available for `{currency}`"))?;
553550

554-
let price_cache = currency_cache
555-
.prices
556-
.get(source_name)
557-
.ok_or_else(|| anyhow!(
551+
let price_cache = currency_cache.prices.get(source_name).ok_or_else(|| {
552+
anyhow!(
558553
"Source `{source_name}` has no data for `{currency}`. \
559554
The source may not support this currency or is currently backing off."
560-
))?;
555+
)
556+
})?;
561557

562558
if price_cache.timestamp + SERVE_TTL <= Instant::now() {
563559
return Err(anyhow!("Cached rate from `{source_name}` is expired"));
@@ -577,3 +573,164 @@ fn get_median(source_results: Vec<SourceResult>) -> f64 {
577573
f64::midpoint(prices[mid - 1], prices[mid])
578574
}
579575
}
576+
577+
#[test]
578+
fn test_sources() {
579+
use crate::add_default_sources;
580+
use serde_json::json;
581+
let mut sources = Vec::new();
582+
add_default_sources(&mut sources, false);
583+
let mut responses = HashMap::new();
584+
585+
let coingecko_price = 72732f64;
586+
let coingecko_response = json!({"bitcoin": {"usd": coingecko_price}});
587+
responses.insert("coingecko", (coingecko_response, coingecko_price));
588+
589+
let kraken_price = 72745.00000;
590+
let kraken_reponse = json!({
591+
"error": [],
592+
"result": {
593+
"XXBTZUSD": {
594+
"a": ["72745.00000", "1", "1.000"],
595+
"b": ["72744.90000", "3", "3.000"],
596+
"c": [kraken_price, "0.00033610"],
597+
"v": ["299.69911717", "640.87872182"],
598+
"p": ["73231.75442", "73435.07382"],
599+
"t": [17317, 39634],
600+
"l": ["72613.70000", "72613.70000"],
601+
"h": ["73960.00000", "74070.00000"],
602+
"o": "73569.90000",
603+
}
604+
},
605+
});
606+
responses.insert("kraken", (kraken_reponse, kraken_price));
607+
608+
let blockchain_info_price = 72749.07;
609+
let blockchain_info_reponse = json!({
610+
"ARS": {
611+
"15m": 1.0250388182e8,
612+
"last": 1.0250388182e8,
613+
"buy": 1.0250388182e8,
614+
"sell": 1.0250388182e8,
615+
"symbol": "ARS",
616+
},
617+
"AUD": {
618+
"15m": 101319.81,
619+
"last": 101319.81,
620+
"buy": 101319.81,
621+
"sell": 101319.81,
622+
"symbol": "AUD",
623+
},
624+
"BRL": {
625+
"15m": 366724.43,
626+
"last": 366724.43,
627+
"buy": 366724.43,
628+
"sell": 366724.43,
629+
"symbol": "BRL",
630+
},
631+
"CAD": {
632+
"15m": 100512.08,
633+
"last": 100512.08,
634+
"buy": 100512.08,
635+
"sell": 100512.08,
636+
"symbol": "CAD",
637+
},
638+
"USD": {
639+
"15m": 72749.07,
640+
"last": blockchain_info_price,
641+
"buy": 72749.07,
642+
"sell": 72749.07,
643+
"symbol": "USD",
644+
},
645+
});
646+
responses.insert(
647+
"blockchain.info",
648+
(blockchain_info_reponse, blockchain_info_price),
649+
);
650+
651+
let bitstamp_price = 72748.45;
652+
let bitstamp_reponse = json!({
653+
"timestamp": "1780301548",
654+
"open": "73568.00",
655+
"high": "74094.65",
656+
"low": "72611.68",
657+
"last": bitstamp_price,
658+
"volume": "619.76380694",
659+
"vwap": "73542.01",
660+
"bid": "72748.45",
661+
"ask": "72748.46",
662+
"side": "1",
663+
"open_24": "73769.45",
664+
"percent_change_24": "-1.38",
665+
"market_type": "SPOT",
666+
});
667+
responses.insert("bitstamp", (bitstamp_reponse, bitstamp_price));
668+
669+
let coindesk_price = 72751.6828660636;
670+
let coindes_response = json!({
671+
"Data": {
672+
"BTC-USD": {
673+
"TYPE": "266",
674+
"MARKET": "cadli",
675+
"INSTRUMENT": "BTC-USD",
676+
"CCSEQ": 1323841393,
677+
"VALUE": coindesk_price,
678+
"VALUE_FLAG": "UP",
679+
"VALUE_LAST_UPDATE_TS": 1780301570,
680+
"VALUE_LAST_UPDATE_TS_NS": 94000000,
681+
"LAST_UPDATE_QUANTITY": 0.105,
682+
"LAST_UPDATE_QUOTE_QUANTITY": 7642.36250656015,
683+
"LAST_UPDATE_VOLUME_TOP_TIER": 0,
684+
"LAST_UPDATE_QUOTE_VOLUME_TOP_TIER": 0,
685+
"LAST_UPDATE_VOLUME_DIRECT": 0,
686+
"LAST_UPDATE_CCSEQ": 1323894738,
687+
"CURRENT_HOUR_VOLUME": 1574.29740546284,
688+
"CURRENT_HOUR_QUOTE_VOLUME": 114448749.088967,
689+
"CURRENT_HOUR_VOLUME_TOP_TIER": 815.533688797,
690+
"CURRENT_HOUR_QUOTE_VOLUME_TOP_TIER": 59271744.4993065,
691+
"CURRENT_YEAR_CHANGE_PERCENTAGE": -16.8891195575271,
692+
"MOVING_24_HOUR_VOLUME": 127405.003580434,
693+
"MOVING_24_HOUR_QUOTE_VOLUME": 9365693546.36435,
694+
"MOVING_24_HOUR_VOLUME_TOP_TIER": 60402.3558377577,
695+
"MOVING_24_HOUR_QUOTE_VOLUME_TOP_TIER_DIRECT": 867716024.677109,
696+
"MOVING_24_HOUR_OPEN": 73850.0780201078,
697+
"MOVING_7_DAY_HIGH": 77996.1623459993,
698+
"MOVING_7_DAY_LOW": 72425.5243349043,
699+
"MOVING_7_DAY_TOTAL_INDEX_UPDATES": 12822192,
700+
"MOVING_7_DAY_CHANGE": -4535.8043571581,
701+
"MOVING_365_DAY_CHANGE_PERCENTAGE": -31.154821757090602,
702+
"LIFETIME_FIRST_UPDATE_TS": 1279408140,
703+
"LIFETIME_QUOTE_VOLUME_TOP_TIER_DIRECT": 4851586542024.82,
704+
"LIFETIME_OPEN": 0.04951,
705+
"LIFETIME_LOW": 0.01,
706+
"LIFETIME_LOW_TS": 1286572500,
707+
"LIFETIME_TOTAL_INDEX_UPDATES": 1329464334,
708+
"LIFETIME_CHANGE": 72751.6333560636,
709+
"LIFETIME_CHANGE_PERCENTAGE": 146943311.16151,
710+
}
711+
},
712+
"Err": {},
713+
});
714+
responses.insert("coindesk", (coindes_response, coindesk_price));
715+
716+
let coinbase_price = 72760.125;
717+
let coinbase_reponse =
718+
json!({"data": {"amount": coinbase_price, "base": "BTC", "currency": "USD"}});
719+
responses.insert("coinbase", (coinbase_reponse, coinbase_price));
720+
721+
let binance_price = 72715.35000000;
722+
let binance_reponse = json!({"symbol": "BTCUSD", "price": binance_price});
723+
responses.insert("binance", (binance_reponse, binance_price));
724+
725+
for source in &sources {
726+
let (response, price) = responses.get(source.name()).unwrap();
727+
let extracted_price = extract_price_from_response(
728+
response,
729+
&source.reply_members("usd", "USD"),
730+
&source.name,
731+
"USD",
732+
)
733+
.unwrap();
734+
assert_eq!(extracted_price, *price, "Failed for {}", source.name());
735+
}
736+
}

0 commit comments

Comments
 (0)