pub(crate) mod client;
pub(crate) mod corporate;
pub(crate) mod crypto;
pub(crate) mod forex;
pub(crate) mod models;
use std::time::Duration;
use crate::adapters::singleton::keyless_limiter;
use crate::error::Result;
use client::GdeltClient;
const GDELT_RATE_PER_SEC: f64 = 0.2;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
keyless_limiter!(rate = GDELT_RATE_PER_SEC);
fn client() -> Result<GdeltClient> {
GdeltClient::new(DEFAULT_TIMEOUT, shared_limiter(), client::GDELT_BASE)
}
pub use corporate::fetch_news_response;
pub use crypto::fetch_crypto_news_response;
pub use forex::fetch_forex_news_response;
#[cfg(test)]
mod tests {
use super::*;
use crate::error::FinanceError;
use crate::rate_limiter::RateLimiter;
use std::sync::Arc;
fn test_client(base_url: &str) -> GdeltClient {
GdeltClient::new(
Duration::from_secs(5),
Arc::new(RateLimiter::new(100.0)),
base_url,
)
.unwrap()
}
fn articles_payload() -> String {
serde_json::json!({
"articles": [
{
"url": "https://example.com/news/aapl-earnings",
"url_mobile": "",
"title": "Apple (NASDAQ: AAPL) beats earnings estimates",
"seendate": "20260805T113000Z",
"socialimage": "https://example.com/img.jpg",
"domain": "example.com",
"language": "English",
"sourcecountry": "United States"
},
{
"url": "https://example.fr/actu/aapl",
"title": "Apple annonce des resultats records",
"seendate": "20260805T090000Z",
"domain": "example.fr",
"language": "French",
"sourcecountry": "France"
}
]
})
.to_string()
}
#[tokio::test]
async fn articles_map_to_canonical_news() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(articles_payload())
.create_async()
.await;
let response = test_client(&server.url())
.article_search("\"AAPL\"", "2w", 50)
.await
.unwrap();
assert_eq!(response.articles.len(), 2);
let news: Vec<_> = response
.articles
.into_iter()
.map(|a| super::corporate::to_news_at(a, chrono::Utc::now()))
.collect();
assert_eq!(news[0].link, "https://example.com/news/aapl-earnings");
assert_eq!(news[0].source, "example.com");
assert_eq!(news[0].img, "https://example.com/img.jpg");
assert_eq!(news[1].img, "");
}
#[tokio::test]
async fn throttled_response_maps_to_rate_limited() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.match_query(mockito::Matcher::Any)
.with_status(429)
.create_async()
.await;
let err = test_client(&server.url())
.article_search("\"AAPL\"", "2w", 50)
.await
.unwrap_err();
assert!(matches!(err, FinanceError::RateLimited { .. }), "{err:?}");
}
#[tokio::test]
async fn plain_text_throttle_message_is_not_treated_as_valid_json() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_body("Please limit requests to one every 5 seconds")
.create_async()
.await;
let err = test_client(&server.url())
.article_search("\"AAPL\"", "2w", 50)
.await
.unwrap_err();
match err {
FinanceError::ResponseStructureError { context, .. } => {
assert!(context.contains("limit requests"), "got {context}");
}
other => panic!("expected ResponseStructureError, got {other:?}"),
}
}
#[tokio::test]
async fn http_error_maps_to_external_api_error() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.match_query(mockito::Matcher::Any)
.with_status(503)
.create_async()
.await;
let err = test_client(&server.url())
.article_search("\"AAPL\"", "2w", 50)
.await
.unwrap_err();
assert!(
matches!(err, FinanceError::ExternalApiError { status: 503, .. }),
"{err:?}"
);
}
#[tokio::test]
async fn empty_articles_array_maps_to_an_empty_vec() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(serde_json::json!({"articles": []}).to_string())
.create_async()
.await;
let response = test_client(&server.url())
.article_search("\"NOSUCHTICKER\"", "2w", 50)
.await
.unwrap();
assert!(response.articles.is_empty());
}
}