use chrono::{Duration, Utc};
use the_odds_api::{Error, Market, OddsFormat, Region, TheOddsApiClient};
fn client() -> TheOddsApiClient {
let _ = dotenvy::dotenv();
let api_key =
std::env::var("ODDS_API_KEY").expect("ODDS_API_KEY must be set in .env or environment");
TheOddsApiClient::new(api_key)
}
fn should_skip(err: &Error) -> bool {
match err {
Error::RateLimited { .. } => {
eprintln!(" SKIPPED: rate limited — try again later or upgrade API plan");
true
}
Error::Unauthorized => {
eprintln!(" SKIPPED: unauthorized — endpoint may require a paid plan");
true
}
_ => false,
}
}
macro_rules! unwrap_or_skip {
($result:expr) => {
match $result {
Ok(v) => v,
Err(ref e) if should_skip(e) => return,
Err(e) => panic!("unexpected error: {e}"),
}
};
}
#[tokio::test]
#[ignore]
async fn test_get_sports() {
let resp = unwrap_or_skip!(client().get_sports().await);
assert!(!resp.data.is_empty(), "should return at least one sport");
let sport = &resp.data[0];
assert!(!sport.key.is_empty(), "sport key should be non-empty");
assert!(!sport.title.is_empty(), "sport title should be non-empty");
assert!(!sport.group.is_empty(), "sport group should be non-empty");
assert!(
!sport.description.is_empty(),
"sport description should be non-empty"
);
for s in &resp.data {
assert!(s.active, "get_sports should only return active sports");
}
}
#[tokio::test]
#[ignore]
async fn test_get_all_sports() {
let client = client();
let resp = unwrap_or_skip!(client.get_all_sports().await);
assert!(!resp.data.is_empty(), "should return at least one sport");
let active_count = resp.data.iter().filter(|s| s.active).count();
let inactive_count = resp.data.iter().filter(|s| !s.active).count();
assert!(active_count > 0, "should have at least one active sport");
assert!(
inactive_count > 0,
"all sports should include at least one inactive sport"
);
}
#[tokio::test]
#[ignore]
async fn test_get_events() {
let client = client();
let sports = unwrap_or_skip!(client.get_sports().await);
assert!(!sports.data.is_empty(), "need at least one active sport");
let sport_key = &sports.data[0].key;
let resp = unwrap_or_skip!(client.get_events(sport_key).send().await);
for event in &resp.data {
assert!(!event.id.is_empty(), "event id should be non-empty");
assert_eq!(
&event.sport_key, sport_key,
"event sport_key should match requested sport"
);
assert!(
!event.sport_title.is_empty(),
"event sport_title should be non-empty"
);
}
}
#[tokio::test]
#[ignore]
async fn test_get_events_with_time_filter() {
let client = client();
let sports = unwrap_or_skip!(client.get_sports().await);
assert!(!sports.data.is_empty());
let sport_key = &sports.data[0].key;
let now = Utc::now();
let in_a_week = now + Duration::days(7);
let resp = unwrap_or_skip!(
client
.get_events(sport_key)
.commence_time_from(now)
.commence_time_to(in_a_week)
.send()
.await
);
for event in &resp.data {
assert!(
event.commence_time >= now - Duration::seconds(1),
"event commence_time {} should be >= now {}",
event.commence_time,
now
);
assert!(
event.commence_time <= in_a_week + Duration::seconds(1),
"event commence_time {} should be <= in_a_week {}",
event.commence_time,
in_a_week
);
}
}
#[tokio::test]
#[ignore]
async fn test_get_odds() {
let client = client();
let sports = unwrap_or_skip!(client.get_sports().await);
assert!(!sports.data.is_empty());
let sport_key = &sports.data[0].key;
let resp = unwrap_or_skip!(
client
.get_odds(sport_key)
.regions(&[Region::Us])
.markets(&[Market::H2h])
.send()
.await
);
assert!(
resp.usage.requests_remaining.is_some(),
"requests_remaining should be present"
);
assert!(
resp.usage.requests_used.is_some(),
"requests_used should be present"
);
for event in &resp.data {
assert!(!event.id.is_empty());
assert_eq!(&event.sport_key, sport_key);
assert!(!event.sport_title.is_empty());
for bookmaker in &event.bookmakers {
assert!(!bookmaker.key.is_empty(), "bookmaker key should be non-empty");
assert!(
!bookmaker.title.is_empty(),
"bookmaker title should be non-empty"
);
assert!(
!bookmaker.markets.is_empty(),
"bookmaker should have at least one market"
);
for market in &bookmaker.markets {
assert_eq!(market.key, "h2h", "requested h2h market");
assert!(
!market.outcomes.is_empty(),
"market should have at least one outcome"
);
for outcome in &market.outcomes {
assert!(!outcome.name.is_empty(), "outcome name should be non-empty");
assert!(outcome.price > 0.0, "decimal odds should be positive");
}
}
}
}
}
#[tokio::test]
#[ignore]
async fn test_get_odds_missing_regions_errors() {
let client = client();
let result = client.get_odds("americanfootball_nfl").send().await;
assert!(result.is_err(), "should error without regions");
assert!(
matches!(
result.unwrap_err(),
the_odds_api::Error::MissingParameter("regions")
),
"should be MissingParameter(regions)"
);
}
#[tokio::test]
#[ignore]
async fn test_get_odds_american_format() {
let client = client();
let sports = unwrap_or_skip!(client.get_sports().await);
assert!(!sports.data.is_empty());
let sport_key = &sports.data[0].key;
let resp = unwrap_or_skip!(
client
.get_odds(sport_key)
.regions(&[Region::Us])
.markets(&[Market::H2h])
.odds_format(OddsFormat::American)
.send()
.await
);
for event in &resp.data {
for bookmaker in &event.bookmakers {
for market in &bookmaker.markets {
for outcome in &market.outcomes {
assert!(
outcome.price.abs() >= 100.0,
"American odds should have absolute value >= 100, got {}",
outcome.price
);
}
}
}
}
}
#[tokio::test]
#[ignore]
async fn test_get_odds_spreads() {
let client = client();
let sports = unwrap_or_skip!(client.get_sports().await);
assert!(!sports.data.is_empty());
let sport_key = &sports.data[0].key;
let resp = unwrap_or_skip!(
client
.get_odds(sport_key)
.regions(&[Region::Us])
.markets(&[Market::Spreads])
.send()
.await
);
for event in &resp.data {
for bookmaker in &event.bookmakers {
for market in &bookmaker.markets {
assert_eq!(market.key, "spreads");
for outcome in &market.outcomes {
assert!(
outcome.point.is_some(),
"spreads outcomes should have a point value"
);
}
}
}
}
}
#[tokio::test]
#[ignore]
async fn test_get_scores() {
let client = client();
let sports = unwrap_or_skip!(client.get_sports().await);
assert!(!sports.data.is_empty());
let sport_key = &sports.data[0].key;
let resp = unwrap_or_skip!(client.get_scores(sport_key).send().await);
for event in &resp.data {
assert!(!event.id.is_empty());
assert_eq!(&event.sport_key, sport_key);
assert!(!event.sport_title.is_empty());
}
}
#[tokio::test]
#[ignore]
async fn test_get_scores_with_days_from() {
let client = client();
let sports = unwrap_or_skip!(client.get_sports().await);
assert!(!sports.data.is_empty());
let sport_key = &sports.data[0].key;
let resp = unwrap_or_skip!(client.get_scores(sport_key).days_from(3).send().await);
for event in &resp.data {
assert!(!event.id.is_empty());
if event.completed {
assert!(
event.scores.is_some(),
"completed event {} should have scores",
event.id
);
let scores = event.scores.as_ref().unwrap();
for score in scores {
assert!(!score.name.is_empty(), "score name should be non-empty");
}
}
}
}
#[tokio::test]
#[ignore]
async fn test_get_event_odds() {
let client = client();
let sports = unwrap_or_skip!(client.get_sports().await);
assert!(!sports.data.is_empty());
let sport_key = &sports.data[0].key;
let events = unwrap_or_skip!(client.get_events(sport_key).send().await);
if events.data.is_empty() {
eprintln!(" SKIPPED: no events available for {sport_key}");
return;
}
let event_id = &events.data[0].id;
let resp = unwrap_or_skip!(
client
.get_event_odds(sport_key, event_id)
.regions(&[Region::Us])
.markets(&[Market::H2h])
.send()
.await
);
assert_eq!(&resp.data.id, event_id, "event id should match requested");
assert_eq!(&resp.data.sport_key, sport_key);
assert!(!resp.data.sport_title.is_empty());
for bookmaker in &resp.data.bookmakers {
assert!(!bookmaker.key.is_empty());
assert!(!bookmaker.title.is_empty());
assert!(!bookmaker.markets.is_empty());
}
}
#[tokio::test]
#[ignore]
async fn test_get_event_odds_missing_regions_errors() {
let client = client();
let result = client
.get_event_odds("americanfootball_nfl", "fake_event_id")
.send()
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
the_odds_api::Error::MissingParameter("regions")
));
}
#[tokio::test]
#[ignore]
async fn test_get_event_markets() {
let client = client();
let sports = unwrap_or_skip!(client.get_sports().await);
assert!(!sports.data.is_empty());
let sport_key = &sports.data[0].key;
let events = unwrap_or_skip!(client.get_events(sport_key).send().await);
if events.data.is_empty() {
eprintln!(" SKIPPED: no events available for {sport_key}");
return;
}
let event_id = &events.data[0].id;
let resp = unwrap_or_skip!(
client
.get_event_markets(sport_key, event_id)
.regions(&[Region::Us])
.send()
.await
);
assert_eq!(&resp.data.id, event_id);
assert_eq!(&resp.data.sport_key, sport_key);
for bookmaker in &resp.data.bookmakers {
assert!(!bookmaker.key.is_empty());
assert!(!bookmaker.title.is_empty());
assert!(
!bookmaker.markets.is_empty(),
"bookmaker {} should list at least one market",
bookmaker.key
);
for market in &bookmaker.markets {
assert!(!market.key.is_empty(), "market key should be non-empty");
}
}
}
#[tokio::test]
#[ignore]
async fn test_get_participants() {
let client = client();
let sports = unwrap_or_skip!(client.get_sports().await);
assert!(!sports.data.is_empty());
let sport_key = &sports.data[0].key;
let resp = unwrap_or_skip!(client.get_participants(sport_key).await);
assert!(
!resp.data.is_empty(),
"should return at least one participant"
);
for participant in &resp.data {
assert!(
!participant.id.is_empty(),
"participant id should be non-empty"
);
assert!(
!participant.full_name.is_empty(),
"participant full_name should be non-empty"
);
}
}
#[tokio::test]
#[ignore]
async fn test_get_historical_events() {
let client = client();
let past_date = Utc::now() - Duration::days(7);
let sports = unwrap_or_skip!(client.get_sports().await);
assert!(!sports.data.is_empty());
let sport_key = &sports.data[0].key;
let resp = unwrap_or_skip!(
client
.get_historical_events(sport_key)
.date(past_date)
.send()
.await
);
let hist = &resp.data;
for event in &hist.data {
assert!(!event.id.is_empty());
assert_eq!(&event.sport_key, sport_key);
assert!(!event.sport_title.is_empty());
}
}
#[tokio::test]
#[ignore]
async fn test_get_historical_events_missing_date_errors() {
let client = client();
let result = client
.get_historical_events("americanfootball_nfl")
.send()
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
the_odds_api::Error::MissingParameter("date")
));
}
#[tokio::test]
#[ignore]
async fn test_get_historical_odds() {
let client = client();
let past_date = Utc::now() - Duration::days(7);
let sports = unwrap_or_skip!(client.get_sports().await);
assert!(!sports.data.is_empty());
let sport_key = &sports.data[0].key;
let resp = unwrap_or_skip!(
client
.get_historical_odds(sport_key)
.date(past_date)
.regions(&[Region::Us])
.markets(&[Market::H2h])
.send()
.await
);
let hist = &resp.data;
for event in &hist.data {
assert!(!event.id.is_empty());
assert_eq!(&event.sport_key, sport_key);
for bookmaker in &event.bookmakers {
assert!(!bookmaker.key.is_empty());
for market in &bookmaker.markets {
assert_eq!(market.key, "h2h");
for outcome in &market.outcomes {
assert!(!outcome.name.is_empty());
assert!(outcome.price > 0.0);
}
}
}
}
}
#[tokio::test]
#[ignore]
async fn test_get_historical_odds_missing_params_errors() {
let client = client();
let result = client
.get_historical_odds("americanfootball_nfl")
.send()
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
the_odds_api::Error::MissingParameter("date")
));
let result = client
.get_historical_odds("americanfootball_nfl")
.date(Utc::now() - Duration::days(7))
.send()
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
the_odds_api::Error::MissingParameter("regions")
));
}
#[tokio::test]
#[ignore]
async fn test_get_historical_event_odds() {
let client = client();
let past_date = Utc::now() - Duration::days(7);
let sports = unwrap_or_skip!(client.get_sports().await);
assert!(!sports.data.is_empty());
let sport_key = &sports.data[0].key;
let hist_events = unwrap_or_skip!(
client
.get_historical_events(sport_key)
.date(past_date)
.send()
.await
);
if hist_events.data.data.is_empty() {
eprintln!(" SKIPPED: no historical events for {sport_key} at {past_date}");
return;
}
let event_id = &hist_events.data.data[0].id;
let snapshot_date = hist_events.data.timestamp;
let resp = unwrap_or_skip!(
client
.get_historical_event_odds(sport_key, event_id)
.date(snapshot_date)
.regions(&[Region::Us])
.markets(&[Market::H2h])
.send()
.await
);
let hist = &resp.data;
assert_eq!(&hist.data.id, event_id);
assert_eq!(&hist.data.sport_key, sport_key);
}
#[tokio::test]
#[ignore]
async fn test_unauthorized_with_bad_key() {
let client = TheOddsApiClient::new("invalid_api_key_12345");
let result = client.get_sports().await;
assert!(result.is_err(), "bad API key should return an error");
assert!(
matches!(result.unwrap_err(), the_odds_api::Error::Unauthorized),
"should be Unauthorized error"
);
}
#[tokio::test]
#[ignore]
async fn test_builder_with_custom_base_url() {
let _ = dotenvy::dotenv();
let api_key =
std::env::var("ODDS_API_KEY").expect("ODDS_API_KEY must be set in .env or environment");
let client = TheOddsApiClient::builder(&api_key)
.base_url("https://api.the-odds-api.com")
.build();
let resp = unwrap_or_skip!(client.get_sports().await);
assert!(!resp.data.is_empty());
}
#[tokio::test]
#[ignore]
async fn test_upcoming_odds() {
let client = client();
let resp = unwrap_or_skip!(
client
.get_upcoming_odds()
.regions(&[Region::Us])
.markets(&[Market::H2h])
.send()
.await
);
for event in &resp.data {
assert!(!event.id.is_empty());
assert!(!event.sport_key.is_empty());
assert!(!event.sport_title.is_empty());
}
}