mod support;
use std::time::{Duration, Instant};
use bitculator::{Client, Error, Params};
use serde_json::Value;
use support::{Response, Server};
fn client(server: &Server) -> Client {
Client::builder("test-key")
.base_url(&server.base_url)
.user_agent("bitculator-rust/test")
.build()
}
fn one_shot(server: &Server) -> Client {
Client::builder("test-key")
.base_url(&server.base_url)
.user_agent("bitculator-rust/test")
.max_retries(0)
.build()
}
fn api_error(result: Result<Value, Error>) -> bitculator::ApiError {
match result {
Err(Error::Api(e)) => e,
other => panic!("expected Error::Api, got {other:?}"),
}
}
#[test]
fn sends_bearer_auth_accept_and_user_agent() {
let server = Server::start(vec![Response::json(200, r#"{"data":{}}"#)]);
client(&server)
.coins()
.get::<Value>("bitcoin", Params::new())
.unwrap();
let request = server.request(0);
assert_eq!(request.header("authorization"), Some("Bearer test-key"));
assert_eq!(request.header("accept"), Some("application/json"));
assert_eq!(request.header("user-agent"), Some("bitculator-rust/test"));
assert_eq!(request.header("content-type"), None);
}
#[test]
fn post_sends_a_json_body() {
let server = Server::start(vec![Response::json(200, r#"{"data":{}}"#)]);
client(&server)
.editorial()
.submit_article_feedback("bitcoin-halving", true)
.unwrap();
let request = server.request(0);
assert_eq!(request.method, "POST");
assert_eq!(request.header("content-type"), Some("application/json"));
assert_eq!(request.body, r#"{"helpful":true}"#);
assert_eq!(request.path(), "/api/v1/articles/bitcoin-halving/feedback");
}
#[test]
fn unwraps_the_data_envelope() {
let server = Server::start(vec![Response::json(
200,
r#"{"data":{"name":"Bitcoin","price":"67412.83451201"}}"#,
)]);
let coin: Value = client(&server)
.coins()
.get("bitcoin", Params::new())
.unwrap();
assert_eq!(coin["name"], "Bitcoin");
assert_eq!(coin["price"], "67412.83451201"); }
#[test]
fn passes_through_bodies_without_a_data_key() {
let server = Server::start(vec![Response::json(200, r#"{"openapi":"3.0.0"}"#)]);
let spec: Value = client(&server)
.coins()
.get("anything", Params::new())
.unwrap();
assert_eq!(spec["openapi"], "3.0.0");
}
#[test]
fn empty_2xx_body_is_null() {
let server = Server::start(vec![Response::json(204, "")]);
let gone: Value = client(&server).webhooks().delete(7).unwrap();
assert!(gone.is_null());
assert_eq!(server.request(0).method, "DELETE");
assert_eq!(server.request(0).path(), "/api/v1/webhooks/7");
}
#[test]
fn malformed_2xx_body_is_a_decode_error() {
let server = Server::start(vec![Response::json(200, "<html>gateway page</html>")]);
let result = client(&server)
.coins()
.get::<Value>("bitcoin", Params::new());
assert!(matches!(result, Err(Error::Decode(_))), "got {result:?}");
}
#[test]
fn parses_quota_headers() {
let server = Server::start(vec![Response::json(200, r#"{"data":[]}"#)
.with_header("X-Quota-Limit", "10000")
.with_header("X-Quota-Remaining", "9871")
.with_header("X-Quota-Reset", "1720569600")]);
let client = client(&server);
assert!(
client.quota().is_none(),
"quota should be None before the first call"
);
client
.coins()
.get::<Value>("bitcoin", Params::new())
.unwrap();
let quota = client.quota().expect("quota after a call");
assert_eq!(quota.limit, Some(10000));
assert_eq!(quota.remaining, Some(9871));
assert_eq!(quota.reset, Some(1720569600));
assert_eq!(
quota.raw.get("x-quota-limit").map(String::as_str),
Some("10000")
);
}
#[test]
fn missing_quota_headers_parse_to_none() {
let server = Server::start(vec![Response::json(200, r#"{"data":[]}"#)]);
let client = client(&server);
client
.coins()
.get::<Value>("bitcoin", Params::new())
.unwrap();
let quota = client.quota().expect("quota after a call");
assert_eq!(
(quota.limit, quota.remaining, quota.reset),
(None, None, None)
);
assert!(quota.raw.is_empty());
}
#[test]
fn params_serialize_csv_bools_and_skip_none() {
let server = Server::start(vec![Response::json(200, r#"{"data":{}}"#)]);
let params = Params::new()
.set("symbols", vec!["btc", "eth", "sol"])
.set("include_inactive", true)
.set("locale", Option::<&str>::None)
.set("empty", Vec::<&str>::new())
.set("page", 1);
client(&server)
.coins()
.get::<Value>("bitcoin", params)
.unwrap();
let request = server.request(0);
assert!(
request.has_query_pair("symbols=btc%2Ceth%2Csol"),
"query = {}",
request.query()
);
assert!(
request.has_query_pair("include_inactive=true"),
"query = {}",
request.query()
);
assert!(
request.has_query_pair("page=1"),
"query = {}",
request.query()
);
assert!(
!request.query().contains("locale"),
"query = {}",
request.query()
);
assert!(
!request.query().contains("empty"),
"query = {}",
request.query()
);
}
#[test]
fn floats_go_on_the_wire_as_plain_decimals() {
let server = Server::start(vec![Response::json(200, r#"{"data":{}}"#)]);
let params = Params::new()
.set("min_price", 1e-7_f64)
.set("max_price", -2.5e-8_f64)
.set("min_marketcap", 1e21_f64);
client(&server)
.coins()
.get::<Value>("bitcoin", params)
.unwrap();
let request = server.request(0);
assert!(
request.has_query_pair("min_price=0.0000001"),
"query = {}",
request.query()
);
assert!(
request.has_query_pair("max_price=-0.000000025"),
"query = {}",
request.query()
);
assert!(
request.has_query_pair("min_marketcap=1000000000000000000000"),
"query = {}",
request.query()
);
}
#[test]
fn percent_encodes_dynamic_path_segments() {
let server = Server::start(vec![Response::json(200, r#"{"data":{}}"#)]);
client(&server)
.coins()
.get::<Value>("weird/slug name", Params::new())
.unwrap();
assert_eq!(
server.request(0).path(),
"/api/v1/coins/weird%2Fslug%20name"
);
}
#[test]
fn base_url_trims_trailing_slashes() {
let server = Server::start(vec![Response::json(200, r#"{"data":{}}"#)]);
let client = Client::builder("test-key")
.base_url(format!("{}///", server.base_url))
.user_agent("bitculator-rust/test")
.build();
client
.coins()
.get::<Value>("bitcoin", Params::new())
.unwrap();
assert_eq!(server.request(0).path(), "/api/v1/coins/bitcoin");
}
#[test]
fn default_user_agent_carries_the_version() {
let server = Server::start(vec![Response::json(200, r#"{"data":{}}"#)]);
let client = Client::builder("test-key")
.base_url(&server.base_url)
.build();
client
.coins()
.get::<Value>("bitcoin", Params::new())
.unwrap();
let ua = server.request(0).header("user-agent").unwrap().to_string();
assert!(ua.starts_with("bitculator-rust/"), "ua = {ua}");
assert!(ua.contains(bitculator::VERSION), "ua = {ua}");
}
type Predicate = fn(&bitculator::ApiError) -> bool;
#[test]
fn maps_statuses_to_predicates() {
let table: &[(u16, Predicate)] = &[
(401, bitculator::ApiError::is_auth),
(403, bitculator::ApiError::is_permission),
(404, bitculator::ApiError::is_not_found),
(422, bitculator::ApiError::is_validation),
(429, bitculator::ApiError::is_rate_limited),
(500, bitculator::ApiError::is_server),
(503, bitculator::ApiError::is_server),
];
for (status, predicate) in table {
let server = Server::start(vec![Response::error(*status)]);
let err = api_error(one_shot(&server).coins().get("bitcoin", Params::new()));
assert_eq!(err.status, *status);
assert!(predicate(&err), "predicate failed for {status}");
assert_eq!(err.code.as_deref(), Some(format!("err_{status}").as_str()));
}
}
#[test]
fn carries_code_message_and_details_from_the_envelope() {
let body = r#"{"error":{"code":"validation_failed","message":"Invalid parameters.","details":{"per_page":["must not be greater than 100"]}}}"#;
let server = Server::start(vec![Response::json(422, body)]);
let err = api_error(one_shot(&server).coins().get("bitcoin", Params::new()));
assert_eq!(err.code.as_deref(), Some("validation_failed"));
assert_eq!(err.message, "Invalid parameters.");
assert!(err.details.is_some());
}
#[test]
fn falls_back_to_top_level_message_then_http_status() {
let server = Server::start(vec![Response::json(500, r#"{"message":"Whoops."}"#)]);
assert_eq!(
api_error(one_shot(&server).coins().get("bitcoin", Params::new())).message,
"Whoops."
);
let server = Server::start(vec![Response::json(502, "Bad Gateway")]);
let err = api_error(one_shot(&server).coins().get("bitcoin", Params::new()));
assert_eq!(err.message, "HTTP 502");
assert_eq!(err.code, None);
}
#[test]
fn captures_request_id_and_retry_after() {
let server = Server::start(vec![
Response::error(500).with_header("X-Request-Id", "req_123")
]);
let err = api_error(one_shot(&server).coins().get("bitcoin", Params::new()));
assert_eq!(err.request_id.as_deref(), Some("req_123"));
let server = Server::start(vec![Response::error(429).with_header("Retry-After", "30")]);
let err = api_error(one_shot(&server).coins().get("bitcoin", Params::new()));
assert_eq!(err.retry_after, Some(30));
let server = Server::start(vec![Response::error(429)]);
let err = api_error(one_shot(&server).coins().get("bitcoin", Params::new()));
assert_eq!(err.retry_after, None);
}
#[test]
fn get_retries_a_5xx_and_succeeds() {
let server = Server::start(vec![Response::error(500), Response::ok()]);
let value: Value = client(&server)
.coins()
.get("bitcoin", Params::new())
.unwrap();
assert_eq!(value, "ok");
assert_eq!(server.hits(), 2);
}
#[test]
fn post_never_retries_a_5xx() {
let server = Server::start(vec![Response::error(500), Response::ok()]);
let err = api_error(
client(&server)
.editorial()
.submit_article_feedback("x", true),
);
assert!(err.is_server());
assert_eq!(server.hits(), 1, "a POST must never be re-sent on a 5xx");
}
#[test]
fn post_does_retry_a_429() {
let server = Server::start(vec![Response::error(429), Response::ok()]);
client(&server)
.editorial()
.submit_article_feedback("x", true)
.unwrap();
assert_eq!(server.hits(), 2);
}
#[test]
fn delete_retries_a_5xx() {
let server = Server::start(vec![Response::error(500), Response::ok()]);
client(&server).webhooks().delete(7).unwrap();
assert_eq!(server.hits(), 2);
}
#[test]
fn gives_up_after_max_retries() {
let server = Server::start(vec![
Response::error(500),
Response::error(500),
Response::error(500),
Response::ok(),
]);
let err = api_error(client(&server).coins().get("bitcoin", Params::new()));
assert!(err.is_server());
assert_eq!(
server.hits(),
3,
"initial attempt + 2 retries; the queued 200 is never reached"
);
}
#[test]
fn honors_retry_after_seconds_on_a_429() {
let server = Server::start(vec![
Response::error(429).with_header("Retry-After", "1"),
Response::ok(),
]);
let started = Instant::now();
client(&server)
.coins()
.get::<Value>("bitcoin", Params::new())
.unwrap();
assert!(
started.elapsed() >= Duration::from_millis(900),
"Retry-After was not honored"
);
assert_eq!(server.hits(), 2);
}
#[test]
fn timed_out_get_retries_then_errors() {
let server = Server::start(vec![
Response::ok().with_delay(Duration::from_millis(500)),
Response::ok().with_delay(Duration::from_millis(500)),
]);
let client = Client::builder("test-key")
.base_url(&server.base_url)
.timeout(Duration::from_millis(100))
.max_retries(1)
.build();
let result = client.coins().get::<Value>("bitcoin", Params::new());
assert!(matches!(result, Err(Error::Timeout)), "got {result:?}");
server.wait_for_hits(2);
}
#[test]
fn timed_out_post_fails_without_retrying() {
let server = Server::start(vec![
Response::ok().with_delay(Duration::from_millis(500)),
Response::ok(),
]);
let client = Client::builder("test-key")
.base_url(&server.base_url)
.timeout(Duration::from_millis(100))
.build();
let result = client.editorial().submit_article_feedback("x", true);
assert!(matches!(result, Err(Error::Timeout)), "got {result:?}");
std::thread::sleep(Duration::from_millis(700));
assert_eq!(
server.hits(),
1,
"a POST must never be re-sent on a timeout"
);
}
fn page_response(page: u32, last: u32) -> Response {
let first = (page - 1) * 2;
let next = if page < last {
format!(r#""/api/v1/coins?page={}""#, page + 1)
} else {
"null".to_string()
};
Response::json(
200,
&format!(
r#"{{"data":[{{"id":{}}},{{"id":{}}}],"meta":{{"current_page":{page},"last_page":{last},"per_page":2,"total":{}}},"links":{{"next":{next}}}}}"#,
first + 1,
first + 2,
last * 2,
),
)
}
#[test]
fn page_exposes_data_meta_and_has_more() {
let server = Server::start(vec![page_response(1, 3)]);
let page = client(&server)
.coins()
.list::<Value>(Params::new().set("per_page", 2))
.unwrap();
assert_eq!(page.data.len(), 2);
assert_eq!(page.meta.current_page, 1);
assert_eq!(page.meta.last_page, 3);
assert!(page.has_more());
}
#[test]
fn next_page_requests_the_following_page_and_preserves_the_query() {
let server = Server::start(vec![page_response(1, 3), page_response(2, 3)]);
let first = client(&server)
.coins()
.list::<Value>(Params::new().set("per_page", 2).set("sort", "rank"))
.unwrap();
let second = first.next_page().unwrap().expect("a second page");
assert_eq!(second.data[0]["id"], 3);
let request = server.request(1);
for pair in ["page=2", "per_page=2", "sort=rank"] {
assert!(
request.has_query_pair(pair),
"second query {} missing {pair}",
request.query()
);
}
}
#[test]
fn next_page_is_none_on_the_last_page() {
let server = Server::start(vec![page_response(3, 3)]);
let page = client(&server)
.coins()
.list::<Value>(Params::new())
.unwrap();
assert!(!page.has_more());
assert!(page.next_page().unwrap().is_none());
}
#[test]
fn auto_paging_walks_every_page_in_order() {
let server = Server::start(vec![
page_response(1, 3),
page_response(2, 3),
page_response(3, 3),
]);
let page = client(&server)
.coins()
.list::<Value>(Params::new().set("per_page", 2))
.unwrap();
let ids: Vec<i64> = page
.auto_paging()
.map(|row| row.unwrap()["id"].as_i64().unwrap())
.collect();
assert_eq!(ids, vec![1, 2, 3, 4, 5, 6]);
assert_eq!(server.hits(), 3);
}
#[test]
fn envelope_without_meta_or_links_is_a_safe_final_page() {
let server = Server::start(vec![Response::json(200, r#"{"data":[{"id":9}]}"#)]);
let page = client(&server)
.coins()
.list::<Value>(Params::new())
.unwrap();
assert_eq!(page.data.len(), 1);
assert!(!page.has_more());
assert!(page.next_page().unwrap().is_none());
}
#[test]
fn null_data_is_an_empty_page() {
let server = Server::start(vec![Response::json(
200,
r#"{"data":null,"meta":{"current_page":1,"last_page":1}}"#,
)]);
let page = client(&server)
.coins()
.list::<Value>(Params::new())
.unwrap();
assert!(page.data.is_empty());
assert!(!page.has_more());
}
#[test]
fn exposes_all_15_resource_groups() {
let client = Client::new("k");
let _ = client.coins();
let _ = client.prices();
let _ = client.markets();
let _ = client.exchanges();
let _ = client.wallets();
let _ = client.global_market();
let _ = client.sentiment();
let _ = client.indicators();
let _ = client.liquidations();
let _ = client.conversion();
let _ = client.calculators();
let _ = client.editorial();
let _ = client.alarms();
let _ = client.webhooks();
let _ = client.meta();
}
#[test]
#[should_panic(expected = "a Data API key is required")]
fn empty_api_key_panics_at_construction() {
let _ = Client::new("");
}