From 6f0ea39aa32391a8fcb7fea5103cd866ab825b63 Mon Sep 17 00:00:00 2001 From: Warittorn Cheevachaipimol Date: Tue, 27 Feb 2024 13:36:35 +0700 Subject: [PATCH 1/3] fix and format --- bothan-binance/src/service.rs | 22 +++++++++------------- bothan-coingecko/Cargo.toml | 2 +- bothan-coingecko/src/service.rs | 14 ++++++++------ 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/bothan-binance/src/service.rs b/bothan-binance/src/service.rs index 51b10e10..84528530 100644 --- a/bothan-binance/src/service.rs +++ b/bothan-binance/src/service.rs @@ -179,19 +179,15 @@ async fn save_datum(data: &Data, cache: &Arc>) { timestamp: ticker.event_time, }; info!("received prices: {:?}", price_data); - match cache.set_data(ticker.symbol.clone(), price_data).await { - Ok(_) => { - info!("successfully set {} in cache", ticker.symbol); - } - Err(CacheError::PendingNotSet) => { - warn!( - "received data for id that was not pending: {}", - ticker.symbol - ); - } - Err(e) => { - error!("error setting data in cache: {:?}", e) - } + let id = price_data.id.clone(); + if cache + .set_data(ticker.symbol.clone(), price_data) + .await + .is_err() + { + warn!("unexpected request to set data for id: {}", id); + } else { + info!("set price for id {}", id); } } } diff --git a/bothan-coingecko/Cargo.toml b/bothan-coingecko/Cargo.toml index b17d13dc..d6a9eeab 100644 --- a/bothan-coingecko/Cargo.toml +++ b/bothan-coingecko/Cargo.toml @@ -17,5 +17,5 @@ reqwest = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } -serde ={ workspace = true } +serde = { workspace = true } tokio = { workspace = true } diff --git a/bothan-coingecko/src/service.rs b/bothan-coingecko/src/service.rs index cb34f554..000aef4e 100644 --- a/bothan-coingecko/src/service.rs +++ b/bothan-coingecko/src/service.rs @@ -3,15 +3,15 @@ use std::sync::Arc; use chrono::NaiveDateTime; use futures::future::join_all; +use futures::stream::FuturesUnordered; use tokio::select; use tokio::sync::RwLock; use tokio::time::{interval, Duration, Interval}; -use tracing::warn; +use tracing::{info, warn}; use bothan_core::cache::{Cache, Error as CacheError}; use bothan_core::service::{Error as ServiceError, Service, ServiceResult}; use bothan_core::types::PriceData; -use futures::stream::FuturesUnordered; use crate::api::types::Market; use crate::api::CoinGeckoRestAPI; @@ -73,7 +73,7 @@ impl Service for CoinGeckoService { } } Err(CacheError::Invalid) => Err(ServiceError::InvalidSymbol), - Err(_) => Err(ServiceError::Pending), + Err(e) => panic!("unexpected error: {}", e), // This should never happen }) .collect(); @@ -172,7 +172,7 @@ async fn update_coin_list( let mut locked = coin_list.write().await; *locked = new_coin_set; } else { - warn!("Failed to get coin list"); + warn!("failed to get coin list"); } } @@ -180,10 +180,12 @@ async fn process_market_data(market: &Market, cache: &Arc>) { if let Ok(price_data) = parse_market(market) { let id = price_data.id.clone(); if cache.set_data(id.clone(), price_data).await.is_err() { - warn!("Unexpected request to set data for id: {}", id); + warn!("unexpected request to set data for id: {}", id); + } else { + info!("set price for id {}", id); } } else { - warn!("Failed to parse date time"); + warn!("failed to parse date time"); } } From 52ccba4b1783d1382c85c424aaa455b5b362556c Mon Sep 17 00:00:00 2001 From: Warittorn Cheevachaipimol Date: Fri, 23 Feb 2024 15:07:13 +0700 Subject: [PATCH 2/3] add unit tests --- bothan-coingecko/Cargo.toml | 5 + bothan-coingecko/src/api/rest.rs | 186 +++++++++++++++++++++++++++++++ bothan-coingecko/src/service.rs | 110 ++++++++++++++++++ bothan-core/src/cache/error.rs | 2 +- bothan-core/src/types.rs | 2 +- 5 files changed, 303 insertions(+), 2 deletions(-) diff --git a/bothan-coingecko/Cargo.toml b/bothan-coingecko/Cargo.toml index d6a9eeab..8f31d22b 100644 --- a/bothan-coingecko/Cargo.toml +++ b/bothan-coingecko/Cargo.toml @@ -19,3 +19,8 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } serde = { workspace = true } tokio = { workspace = true } + + +[dev-dependencies] +mockito = "1.3.0" +serde_json = "1.0.64" diff --git a/bothan-coingecko/src/api/rest.rs b/bothan-coingecko/src/api/rest.rs index c489217f..54bf1e56 100644 --- a/bothan-coingecko/src/api/rest.rs +++ b/bothan-coingecko/src/api/rest.rs @@ -63,3 +63,189 @@ async fn send_request(request_builder: RequestBuilder) -> Result(response: Response) -> Result { Ok(response.json::().await?) } + +#[cfg(test)] +pub(crate) mod test { + use mockito::{Matcher, Mock, Server, ServerGuard}; + + use crate::api::CoinGeckoRestAPIBuilder; + + use super::*; + + pub(crate) fn setup() -> (ServerGuard, CoinGeckoRestAPI) { + let server = Server::new(); + + let mut builder = CoinGeckoRestAPIBuilder::default(); + builder.set_url(&server.url()); + let api = builder.build().unwrap(); + + (server, api) + } + + pub(crate) trait MockGecko { + fn set_successful_coin_list(&mut self, coin_list: &[Coin]) -> Mock; + fn set_failed_coin_list(&mut self) -> Mock; + fn set_successful_coins_market(&mut self, ids: &[&str], market: &[Market]) -> Vec; + fn set_arbitrary_coins_market>( + &mut self, + ids: &[&str], + data: StrOrBytes, + ) -> Mock; + fn set_failed_coins_market(&mut self, ids: &[&str]) -> Mock; + } + + impl MockGecko for ServerGuard { + fn set_successful_coin_list(&mut self, coin_list: &[Coin]) -> Mock { + self.mock("GET", "/coins/list") + .with_status(200) + .with_body(serde_json::to_string(coin_list).unwrap()) + .create() + } + + fn set_failed_coin_list(&mut self) -> Mock { + self.mock("GET", "/coins/list").with_status(500).create() + } + + fn set_successful_coins_market( + &mut self, + ids: &[&str], + coins_market: &[Market], + ) -> Vec { + let page_results = coins_market + .chunks(250) + .enumerate() + .collect::>(); + + let joined_id = ids.join(","); + + page_results + .into_iter() + .map(|(page, result)| { + self.mock("GET", "/coins/markets") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("ids".into(), joined_id.clone()), + Matcher::UrlEncoded("vs_currency".into(), "usd".into()), + Matcher::UrlEncoded("per_page".into(), 250.to_string()), + Matcher::UrlEncoded("page".into(), (page + 1).to_string()), + ])) + .with_status(200) + .with_body(serde_json::to_string(result).unwrap()) + .create() + }) + .collect() + } + + fn set_arbitrary_coins_market>( + &mut self, + ids: &[&str], + data: StrOrBytes, + ) -> Mock { + self.mock("GET", "/coins/markets") + .match_query(Matcher::UrlEncoded("ids".into(), ids.join(","))) + .with_status(200) + .with_body(data) + .create() + } + + fn set_failed_coins_market(&mut self, ids: &[&str]) -> Mock { + self.mock("GET", "/coins/markets") + .match_query(Matcher::UrlEncoded("ids".into(), ids.join(","))) + .with_status(500) + .create() + } + } + + #[tokio::test] + async fn test_successful_get_coin_list() { + let (mut server, client) = setup(); + + let coin_list = vec![Coin { + id: "bitcoin".to_string(), + symbol: "btc".to_string(), + name: "Bitcoin".to_string(), + }]; + let mock = server.set_successful_coin_list(&coin_list); + + let result = client.get_coins_list().await; + mock.assert(); + assert_eq!(result.unwrap(), coin_list); + } + + #[tokio::test] + async fn test_unsuccessful_get_coin_list() { + let (mut server, client) = setup(); + + let mock = server.set_failed_coin_list(); + + let result = client.get_coins_list().await; + mock.assert(); + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_successful_get_coin_market() { + let (mut server, client) = setup(); + + let coin_markets = vec![Market { + id: "bitcoin".to_string(), + symbol: "btc".to_string(), + name: "Bitcoin".to_string(), + current_price: 42000.69, + last_updated: "1970-01-01T00:00:00.000Z".to_string(), + }]; + let mocks = server.set_successful_coins_market(&["bitcoin"], &coin_markets); + + let result = client.get_coins_market(&["bitcoin"], 250, 1).await; + let expected_result = coin_markets.into_iter().map(Some).collect(); + mocks.iter().for_each(|m| m.assert()); + assert_eq!(result, Ok(expected_result)); + } + + #[tokio::test] + async fn test_get_coin_market_with_missing_data() { + let (mut server, client) = setup(); + + let coin_markets = vec![Market { + id: "bitcoin".to_string(), + symbol: "btc".to_string(), + name: "Bitcoin".to_string(), + current_price: 42000.69, + last_updated: "1970-01-01T00:00:00.000Z".to_string(), + }]; + let ids = &["bitcoin", "abba"]; + let mocks = server.set_successful_coins_market(ids, &coin_markets); + + let result = client.get_coins_market(ids, 250, 1).await; + + mocks.iter().for_each(|m| m.assert()); + let expected_result = vec![Some(coin_markets[0].clone()), None]; + assert_eq!(result, Ok(expected_result)); + } + + #[tokio::test] + async fn test_get_coin_market_with_unparseable_data() { + let (mut server, client) = setup(); + + let ids = &["apple_pie"]; + let mock = server.set_arbitrary_coins_market(ids, "abc"); + + let result = client.get_coins_market(ids, 250, 1).await; + + mock.assert(); + + let expected_err = Error::Reqwest( + "error decoding response body: expected value at line 1 column 1".to_string(), + ); + assert_eq!(result, Err(expected_err)); + } + + #[tokio::test] + async fn test_failed_get_coin_market() { + let (mut server, client) = setup(); + let mock = server.set_failed_coins_market(&["bitcoin"]); + + let result = client.get_coins_market(&["bitcoin"], 250, 1).await; + mock.assert(); + assert!(result.is_err()); + } +} diff --git a/bothan-coingecko/src/service.rs b/bothan-coingecko/src/service.rs index 000aef4e..dd6df505 100644 --- a/bothan-coingecko/src/service.rs +++ b/bothan-coingecko/src/service.rs @@ -202,3 +202,113 @@ fn parse_market(market: &Market) -> Result { timestamp, )) } + +#[cfg(test)] +mod test { + use mockito::ServerGuard; + + use crate::api::rest::test::{setup as api_setup, MockGecko}; + use crate::api::types::Coin; + + use super::*; + + fn setup() -> (Arc, Arc>, ServerGuard) { + let cache = Arc::new(Cache::::new(None)); + let (server, rest_api) = api_setup(); + (Arc::new(rest_api), cache, server) + } + + #[tokio::test] + async fn test_update_price_data() { + let (rest_api, cache, mut server) = setup(); + let coin_market = vec![Market { + id: "bitcoin".to_string(), + symbol: "BTC".to_string(), + name: "Bitcoin".to_string(), + current_price: 8426.69, + last_updated: "2021-01-01T00:00:00.000Z".to_string(), + }]; + server.set_successful_coins_market(&["bitcoin"], &coin_market); + cache.set_batch_pending(vec!["bitcoin".to_string()]).await; + + update_price_data(&rest_api, &cache, 250, None).await; + let result = cache.get("bitcoin").await; + let expected = PriceData::new("bitcoin".to_string(), "8426.69".to_string(), 1609459200); + assert_eq!(result, Ok(expected)); + } + + #[tokio::test] + async fn test_update_coin_list() { + let (rest_api, _, mut server) = setup(); + let coin_list_store = Arc::new(RwLock::new(HashSet::::new())); + let coin_list = vec![Coin { + id: "bitcoin".to_string(), + symbol: "BTC".to_string(), + name: "Bitcoin".to_string(), + }]; + server.set_successful_coin_list(&coin_list); + + update_coin_list(&rest_api, &coin_list_store).await; + assert!(coin_list_store.read().await.contains("bitcoin")); + } + + #[tokio::test] + async fn test_process_market_data() { + let cache = Arc::new(Cache::::new(None)); + let market = Market { + id: "bitcoin".to_string(), + symbol: "BTC".to_string(), + name: "Bitcoin".to_string(), + current_price: 8426.69, + last_updated: "2021-01-01T00:00:00.000Z".to_string(), + }; + + cache.set_batch_pending(vec!["bitcoin".to_string()]).await; + process_market_data(&market, &cache).await; + let result = cache.get("bitcoin").await; + let expected = PriceData::new("bitcoin".to_string(), "8426.69".to_string(), 1609459200); + assert_eq!(result.unwrap(), expected); + } + + #[tokio::test] + async fn test_process_market_data_without_set_pending() { + let cache = Arc::new(Cache::::new(None)); + let market = Market { + id: "bitcoin".to_string(), + symbol: "BTC".to_string(), + name: "Bitcoin".to_string(), + current_price: 8426.69, + last_updated: "2021-01-01T00:00:00.000Z".to_string(), + }; + + process_market_data(&market, &cache).await; + let result = cache.get("bitcoin").await; + assert!(result.is_err()); + } + + #[test] + fn test_parse_market() { + let market = Market { + id: "bitcoin".to_string(), + symbol: "BTC".to_string(), + name: "Bitcoin".to_string(), + current_price: 8426.69, + last_updated: "2021-01-01T00:00:00.000Z".to_string(), + }; + let result = parse_market(&market); + let expected = PriceData::new("bitcoin".to_string(), "8426.69".to_string(), 1609459200); + assert_eq!(result.unwrap(), expected); + } + + #[test] + fn test_parse_market_with_failure() { + let market = Market { + id: "bitcoin".to_string(), + symbol: "BTC".to_string(), + name: "Bitcoin".to_string(), + current_price: 8426.69, + last_updated: "johnny appleseed".to_string(), + }; + assert!(parse_market(&market).is_err()); + } +} diff --git a/bothan-core/src/cache/error.rs b/bothan-core/src/cache/error.rs index 4f2a624d..12a61cb3 100644 --- a/bothan-core/src/cache/error.rs +++ b/bothan-core/src/cache/error.rs @@ -1,4 +1,4 @@ -#[derive(Debug, thiserror::Error)] +#[derive(Clone, Debug, PartialEq, thiserror::Error)] pub enum Error { #[error("symbol value does not exist")] DoesNotExist, diff --git a/bothan-core/src/types.rs b/bothan-core/src/types.rs index 13ae0745..8f55e505 100644 --- a/bothan-core/src/types.rs +++ b/bothan-core/src/types.rs @@ -1,7 +1,7 @@ use derive_more::Display; use serde::Deserialize; -#[derive(Clone, Debug, Deserialize, Display)] +#[derive(Clone, Debug, PartialEq, Deserialize, Display)] #[display("id: {}, price: {}, timestamp: {}", id, price, timestamp)] pub struct PriceData { pub id: String, From 4e308a3fc067a6603841f166ee3ba9aac78cace6 Mon Sep 17 00:00:00 2001 From: Warittorn Cheevachaipimol Date: Tue, 27 Feb 2024 17:04:51 +0700 Subject: [PATCH 3/3] rename mockgecko to mockcoingecko --- bothan-coingecko/src/api/rest.rs | 4 ++-- bothan-coingecko/src/service.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bothan-coingecko/src/api/rest.rs b/bothan-coingecko/src/api/rest.rs index 54bf1e56..ae7b9249 100644 --- a/bothan-coingecko/src/api/rest.rs +++ b/bothan-coingecko/src/api/rest.rs @@ -82,7 +82,7 @@ pub(crate) mod test { (server, api) } - pub(crate) trait MockGecko { + pub(crate) trait MockCoinGecko { fn set_successful_coin_list(&mut self, coin_list: &[Coin]) -> Mock; fn set_failed_coin_list(&mut self) -> Mock; fn set_successful_coins_market(&mut self, ids: &[&str], market: &[Market]) -> Vec; @@ -94,7 +94,7 @@ pub(crate) mod test { fn set_failed_coins_market(&mut self, ids: &[&str]) -> Mock; } - impl MockGecko for ServerGuard { + impl MockCoinGecko for ServerGuard { fn set_successful_coin_list(&mut self, coin_list: &[Coin]) -> Mock { self.mock("GET", "/coins/list") .with_status(200) diff --git a/bothan-coingecko/src/service.rs b/bothan-coingecko/src/service.rs index dd6df505..1d31a434 100644 --- a/bothan-coingecko/src/service.rs +++ b/bothan-coingecko/src/service.rs @@ -207,7 +207,7 @@ fn parse_market(market: &Market) -> Result { mod test { use mockito::ServerGuard; - use crate::api::rest::test::{setup as api_setup, MockGecko}; + use crate::api::rest::test::{setup as api_setup, MockCoinGecko}; use crate::api::types::Coin; use super::*;