use std::fs;
use std::path::Path;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant, SystemTime};
use crate::celestrak::filter::{apply_filters, apply_limit, apply_order_by};
use crate::celestrak::query::CelestrakQuery;
use crate::celestrak::responses::CelestrakSATCATRecord;
use crate::celestrak::types::{CelestrakOutputFormat, SupGPSource};
use crate::propagators::SGPPropagator;
use crate::types::GPRecord;
use crate::utils::{BraheError, atomic_write, get_celestrak_cache_dir};
const DEFAULT_BASE_URL: &str = "https://celestrak.org";
const DEFAULT_MAX_CACHE_AGE: f64 = 7200.0;
const DEFAULT_MAX_RETRIES: u32 = 3;
const MIN_REQUEST_INTERVAL: Duration = Duration::from_millis(500);
static LAST_REQUEST_TIME: LazyLock<Mutex<Option<Instant>>> = LazyLock::new(|| Mutex::new(None));
pub struct CelestrakClient {
base_url: String,
cache_max_age: f64,
max_retries: u32,
agent: ureq::Agent,
}
impl CelestrakClient {
pub fn new() -> Self {
CelestrakClient {
base_url: DEFAULT_BASE_URL.to_string(),
cache_max_age: DEFAULT_MAX_CACHE_AGE,
max_retries: DEFAULT_MAX_RETRIES,
agent: ureq::Agent::new_with_defaults(),
}
}
pub fn with_cache_age(cache_max_age: f64) -> Self {
CelestrakClient {
base_url: DEFAULT_BASE_URL.to_string(),
cache_max_age,
max_retries: DEFAULT_MAX_RETRIES,
agent: ureq::Agent::new_with_defaults(),
}
}
pub fn with_base_url(base_url: &str) -> Self {
CelestrakClient {
base_url: base_url.trim_end_matches('/').to_string(),
cache_max_age: DEFAULT_MAX_CACHE_AGE,
max_retries: DEFAULT_MAX_RETRIES,
agent: ureq::Agent::new_with_defaults(),
}
}
pub fn with_base_url_and_cache_age(base_url: &str, cache_max_age: f64) -> Self {
CelestrakClient {
base_url: base_url.trim_end_matches('/').to_string(),
cache_max_age,
max_retries: DEFAULT_MAX_RETRIES,
agent: ureq::Agent::new_with_defaults(),
}
}
pub fn max_retries(mut self, max_retries: u32) -> Self {
self.max_retries = max_retries;
self
}
pub fn query_raw(&self, query: &CelestrakQuery) -> Result<String, BraheError> {
let url = self.build_full_url(query);
self.fetch_with_cache(&url)
}
pub fn download(&self, query: &CelestrakQuery, filepath: &Path) -> Result<(), BraheError> {
let body = self.query_raw(query)?;
if let Some(parent) = filepath.parent() {
fs::create_dir_all(parent)
.map_err(|e| BraheError::IoError(format!("Failed to create directories: {}", e)))?;
}
atomic_write(filepath, body.as_bytes())
.map_err(|e| BraheError::IoError(format!("Failed to write file: {}", e)))
}
pub fn query_gp(&self, query: &CelestrakQuery) -> Result<Vec<GPRecord>, BraheError> {
let json_query = if query.output_format().is_some_and(|f| f.is_json()) {
query.clone()
} else {
query.clone().format(CelestrakOutputFormat::Json)
};
let body = self.query_raw(&json_query)?;
let mut records: Vec<GPRecord> = serde_json::from_str(&body).map_err(|e| {
BraheError::ParseError(format!(
"Failed to parse CelestrakClient GP response: {}",
e
))
})?;
records = apply_filters(records, query.client_side_filters());
apply_order_by(&mut records, query.client_side_order_by());
records = apply_limit(records, query.client_side_limit());
Ok(records)
}
pub fn query_satcat(
&self,
query: &CelestrakQuery,
) -> Result<Vec<CelestrakSATCATRecord>, BraheError> {
let json_query = if query.output_format().is_some_and(|f| f.is_json()) {
query.clone()
} else {
query.clone().format(CelestrakOutputFormat::Json)
};
let body = self.query_raw(&json_query)?;
let mut records: Vec<CelestrakSATCATRecord> = serde_json::from_str(&body).map_err(|e| {
BraheError::ParseError(format!(
"Failed to parse CelestrakClient SATCAT response: {}",
e
))
})?;
records = apply_filters(records, query.client_side_filters());
apply_order_by(&mut records, query.client_side_order_by());
records = apply_limit(records, query.client_side_limit());
Ok(records)
}
pub fn get_gp_by_catnr(&self, catnr: u32) -> Result<Vec<GPRecord>, BraheError> {
let query = CelestrakQuery::gp().catnr(catnr);
self.query_gp(&query)
}
pub fn get_gp_by_group(&self, group: &str) -> Result<Vec<GPRecord>, BraheError> {
let query = CelestrakQuery::gp().group(group);
self.query_gp(&query)
}
pub fn get_gp_by_name(&self, name: &str) -> Result<Vec<GPRecord>, BraheError> {
let query = CelestrakQuery::gp().name_search(name);
self.query_gp(&query)
}
pub fn get_gp_by_intdes(&self, intdes: &str) -> Result<Vec<GPRecord>, BraheError> {
let query = CelestrakQuery::gp().intdes(intdes);
self.query_gp(&query)
}
pub fn get_sup_gp(&self, source: SupGPSource) -> Result<Vec<GPRecord>, BraheError> {
let query = CelestrakQuery::sup_gp().source(source);
self.query_gp(&query)
}
pub fn get_satcat_by_catnr(
&self,
catnr: u32,
) -> Result<Vec<CelestrakSATCATRecord>, BraheError> {
let query = CelestrakQuery::satcat().catnr(catnr);
self.query_satcat(&query)
}
pub fn get_sgp_propagator_by_catnr(
&self,
catnr: u32,
step_size: f64,
) -> Result<SGPPropagator, BraheError> {
let records = self.get_gp_by_catnr(catnr)?;
let record = records.first().ok_or_else(|| {
BraheError::Error(format!(
"No GP records found for NORAD catalog number {}",
catnr
))
})?;
SGPPropagator::from_gp_record(record, step_size)
}
fn build_full_url(&self, query: &CelestrakQuery) -> String {
let endpoint = query.query_type().endpoint_path();
let params = query.build_url();
if params.is_empty() {
format!("{}{}", self.base_url, endpoint)
} else {
format!("{}{}?{}", self.base_url, endpoint, params)
}
}
fn fetch_with_cache(&self, url: &str) -> Result<String, BraheError> {
let cache_key = self.cache_key_for_url(url);
if let Some(cached) = self.read_cache(&cache_key)? {
return Ok(cached);
}
let body = self.execute_get(url)?;
self.write_cache(&cache_key, &body)?;
Ok(body)
}
fn cache_key_for_url(&self, url: &str) -> String {
url.chars()
.map(|c| {
if c.is_alphanumeric() || c == '.' {
c
} else {
'_'
}
})
.collect()
}
fn read_cache(&self, cache_key: &str) -> Result<Option<String>, BraheError> {
let cache_dir = get_celestrak_cache_dir()?;
let cache_path = Path::new(&cache_dir).join(cache_key);
if !cache_path.exists() {
return Ok(None);
}
if self.is_cache_stale(&cache_path)? {
return Ok(None);
}
let contents = fs::read_to_string(&cache_path)
.map_err(|e| BraheError::IoError(format!("Failed to read cache file: {}", e)))?;
Ok(Some(contents))
}
fn write_cache(&self, cache_key: &str, data: &str) -> Result<(), BraheError> {
let cache_dir = get_celestrak_cache_dir()?;
let cache_path = Path::new(&cache_dir).join(cache_key);
atomic_write(&cache_path, data.as_bytes())
.map_err(|e| BraheError::IoError(format!("Failed to write cache file: {}", e)))
}
fn is_cache_stale(&self, path: &Path) -> Result<bool, BraheError> {
let metadata = fs::metadata(path)
.map_err(|e| BraheError::IoError(format!("Failed to read file metadata: {}", e)))?;
let modified = metadata.modified().map_err(|e| {
BraheError::IoError(format!("Failed to read file modification time: {}", e))
})?;
let age = SystemTime::now()
.duration_since(modified)
.unwrap_or_default();
Ok(age.as_secs_f64() > self.cache_max_age)
}
fn execute_get(&self, url: &str) -> Result<String, BraheError> {
let mut last_error = None;
for attempt in 0..=self.max_retries {
Self::rate_limit_wait();
if attempt > 0 {
let base_delay = Duration::from_secs(1) * 2u32.pow(attempt - 1);
let nanos = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
let jitter = Duration::from_millis((nanos % 500) as u64);
std::thread::sleep(base_delay + jitter);
}
match self.agent.get(url).call() {
Ok(mut response) => {
return response.body_mut().read_to_string().map_err(|e| {
BraheError::IoError(format!(
"Failed to read CelestrakClient response: {}",
e
))
});
}
Err(e) => {
if attempt < self.max_retries && Self::is_retryable_error(&e) {
last_error = Some(e);
continue;
}
return Err(BraheError::IoError(format!(
"CelestrakClient request failed: {}",
e
)));
}
}
}
Err(BraheError::IoError(format!(
"CelestrakClient request failed: {}",
last_error.unwrap()
)))
}
fn is_retryable_error(e: &ureq::Error) -> bool {
matches!(
e,
ureq::Error::StatusCode(429 | 500 | 502 | 503 | 504)
| ureq::Error::Io(_)
| ureq::Error::Timeout(_)
| ureq::Error::ConnectionFailed
)
}
fn rate_limit_wait() {
let mut last_time = LAST_REQUEST_TIME.lock().unwrap();
if let Some(last) = *last_time {
let elapsed = last.elapsed();
if elapsed < MIN_REQUEST_INTERVAL {
std::thread::sleep(MIN_REQUEST_INTERVAL - elapsed);
}
}
*last_time = Some(Instant::now());
}
}
impl Default for CelestrakClient {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
use httpmock::prelude::*;
#[test]
fn test_client_creation() {
let client = CelestrakClient::new();
assert_eq!(client.base_url, DEFAULT_BASE_URL);
assert_eq!(client.cache_max_age, DEFAULT_MAX_CACHE_AGE);
}
#[test]
fn test_client_with_cache_age() {
let client = CelestrakClient::with_cache_age(3600.0);
assert_eq!(client.base_url, DEFAULT_BASE_URL);
assert_eq!(client.cache_max_age, 3600.0);
}
#[test]
fn test_client_with_base_url() {
let client = CelestrakClient::with_base_url("https://test.celestrak.org/");
assert_eq!(client.base_url, "https://test.celestrak.org");
}
#[test]
fn test_client_with_base_url_no_trailing_slash() {
let client = CelestrakClient::with_base_url("https://test.celestrak.org");
assert_eq!(client.base_url, "https://test.celestrak.org");
}
#[test]
fn test_client_with_base_url_and_cache_age() {
let client =
CelestrakClient::with_base_url_and_cache_age("https://test.celestrak.org", 1800.0);
assert_eq!(client.base_url, "https://test.celestrak.org");
assert_eq!(client.cache_max_age, 1800.0);
}
#[test]
fn test_client_default() {
let client = CelestrakClient::default();
assert_eq!(client.base_url, DEFAULT_BASE_URL);
}
#[test]
fn test_build_full_url_gp_with_params() {
let client = CelestrakClient::new();
let query = CelestrakQuery::gp().group("stations");
let url = client.build_full_url(&query);
assert_eq!(
url,
"https://celestrak.org/NORAD/elements/gp.php?GROUP=stations"
);
}
#[test]
fn test_build_full_url_gp_empty() {
let client = CelestrakClient::new();
let query = CelestrakQuery::gp();
let url = client.build_full_url(&query);
assert_eq!(url, "https://celestrak.org/NORAD/elements/gp.php");
}
#[test]
fn test_build_full_url_sup_gp() {
let client = CelestrakClient::new();
let query = CelestrakQuery::sup_gp().source(crate::celestrak::SupGPSource::SpaceX);
let url = client.build_full_url(&query);
assert_eq!(
url,
"https://celestrak.org/NORAD/elements/supplemental/sup-gp.php?SOURCE=spacex"
);
}
#[test]
fn test_build_full_url_satcat() {
let client = CelestrakClient::new();
let query = CelestrakQuery::satcat().active(true);
let url = client.build_full_url(&query);
assert_eq!(url, "https://celestrak.org/satcat/records.php?ACTIVE=Y");
}
#[test]
fn test_query_raw_gp() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET)
.path("/NORAD/elements/gp.php")
.query_param("GROUP", "stations")
.query_param("FORMAT", "JSON");
then.status(200)
.body(r#"[{"OBJECT_NAME":"ISS (ZARYA)","NORAD_CAT_ID":"25544"}]"#);
});
let client = CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0);
let query = CelestrakQuery::gp()
.group("stations")
.format(CelestrakOutputFormat::Json);
let result = client.query_raw(&query);
assert!(result.is_ok());
assert!(result.unwrap().contains("ISS"));
}
#[test]
fn test_query_gp_typed() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET)
.path("/NORAD/elements/gp.php")
.query_param("GROUP", "stations");
then.status(200).body(
r#"[{
"OBJECT_NAME": "ISS (ZARYA)",
"NORAD_CAT_ID": "25544",
"INCLINATION": "51.6400",
"ECCENTRICITY": "0.00010000"
}]"#,
);
});
let client = CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0);
let query = CelestrakQuery::gp().group("stations");
let result = client.query_gp(&query);
assert!(result.is_ok());
let records = result.unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].object_name.as_deref(), Some("ISS (ZARYA)"));
assert_eq!(records[0].norad_cat_id, Some(25544));
}
#[test]
fn test_query_gp_with_client_side_filter() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/NORAD/elements/gp.php");
then.status(200).body(
r#"[
{"OBJECT_NAME": "ISS (ZARYA)", "NORAD_CAT_ID": "25544", "INCLINATION": "51.64", "OBJECT_TYPE": "PAYLOAD"},
{"OBJECT_NAME": "COSMOS DEB", "NORAD_CAT_ID": "33767", "INCLINATION": "74.03", "OBJECT_TYPE": "DEBRIS"},
{"OBJECT_NAME": "NOAA 18", "NORAD_CAT_ID": "28654", "INCLINATION": "98.70", "OBJECT_TYPE": "PAYLOAD"}
]"#,
);
});
let client = CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0);
let query = CelestrakQuery::gp()
.group("active")
.filter("OBJECT_TYPE", "<>DEBRIS")
.filter("INCLINATION", ">60");
let result = client.query_gp(&query);
assert!(result.is_ok());
let records = result.unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].object_name.as_deref(), Some("NOAA 18"));
}
#[test]
fn test_query_gp_with_order_and_limit() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/NORAD/elements/gp.php");
then.status(200).body(
r#"[
{"OBJECT_NAME": "A", "NORAD_CAT_ID": "100", "INCLINATION": "30"},
{"OBJECT_NAME": "B", "NORAD_CAT_ID": "200", "INCLINATION": "60"},
{"OBJECT_NAME": "C", "NORAD_CAT_ID": "300", "INCLINATION": "90"}
]"#,
);
});
let client = CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0);
let query = CelestrakQuery::gp()
.group("active")
.order_by("INCLINATION", false)
.limit(2);
let result = client.query_gp(&query);
assert!(result.is_ok());
let records = result.unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0].object_name.as_deref(), Some("C"));
assert_eq!(records[1].object_name.as_deref(), Some("B"));
}
#[test]
fn test_query_satcat_typed() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET)
.path("/satcat/records.php")
.query_param("ACTIVE", "Y");
then.status(200).body(
r#"[{
"OBJECT_NAME": "ISS (ZARYA)",
"NORAD_CAT_ID": "25544",
"OBJECT_TYPE": "PAY",
"OWNER": "ISS"
}]"#,
);
});
let client = CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0);
let query = CelestrakQuery::satcat().active(true);
let result = client.query_satcat(&query);
assert!(result.is_ok());
let records = result.unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].object_name.as_deref(), Some("ISS (ZARYA)"));
}
#[test]
fn test_query_raw_tle_format() {
let server = MockServer::start();
let tle_data = "ISS (ZARYA)\n1 25544U 98067A 24015.50000000\n2 25544 51.6400";
server.mock(|when, then| {
when.method(GET)
.path("/NORAD/elements/gp.php")
.query_param("GROUP", "stations")
.query_param("FORMAT", "3LE");
then.status(200).body(tle_data);
});
let client = CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0);
let query = CelestrakQuery::gp()
.group("stations")
.format(CelestrakOutputFormat::ThreeLe);
let result = client.query_raw(&query);
assert!(result.is_ok());
assert!(result.unwrap().contains("25544"));
}
#[test]
fn test_http_error_404() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/NORAD/elements/gp.php");
then.status(404).body("Not Found");
});
let client = CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0);
let query = CelestrakQuery::gp().group("nonexistent");
let result = client.query_raw(&query);
assert!(result.is_err());
}
#[test]
fn test_invalid_json_response() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/NORAD/elements/gp.php");
then.status(200).body("this is not json");
});
let client = CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0);
let query = CelestrakQuery::gp().group("stations");
let result = client.query_gp(&query);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("parse"));
}
#[test]
fn test_empty_json_response() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/NORAD/elements/gp.php");
then.status(200).body("[]");
});
let client = CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0);
let query = CelestrakQuery::gp().group("stations");
let result = client.query_gp(&query);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_download_to_file() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/NORAD/elements/gp.php");
then.status(200).body("test data content");
});
let client = CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0);
let temp_dir = std::env::temp_dir().join("brahe_test_celestrak_download");
let _ = fs::remove_dir_all(&temp_dir);
let filepath = temp_dir.join("test_output.txt");
let query = CelestrakQuery::gp()
.group("stations")
.format(CelestrakOutputFormat::ThreeLe);
let result = client.download(&query, &filepath);
assert!(result.is_ok());
assert!(filepath.exists());
let contents = fs::read_to_string(&filepath).unwrap();
assert_eq!(contents, "test data content");
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_cache_key_generation() {
let client = CelestrakClient::new();
let key = client.cache_key_for_url("https://celestrak.org/gp.php?GROUP=stations");
assert!(key.contains("celestrak.org"));
assert!(key.contains("GROUP"));
assert!(!key.contains("?"));
assert!(!key.contains("/"));
}
#[test]
fn test_get_gp_by_catnr() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET)
.path("/NORAD/elements/gp.php")
.query_param("CATNR", "25544");
then.status(200).body(
r#"[{
"OBJECT_NAME": "ISS (ZARYA)",
"NORAD_CAT_ID": "25544",
"INCLINATION": "51.6400"
}]"#,
);
});
let client = CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0);
let records = client.get_gp_by_catnr(25544).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].norad_cat_id, Some(25544));
}
#[test]
fn test_get_gp_by_group() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET)
.path("/NORAD/elements/gp.php")
.query_param("GROUP", "stations");
then.status(200).body(
r#"[{
"OBJECT_NAME": "ISS (ZARYA)",
"NORAD_CAT_ID": "25544"
}]"#,
);
});
let client = CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0);
let records = client.get_gp_by_group("stations").unwrap();
assert_eq!(records.len(), 1);
}
#[test]
fn test_get_gp_by_name() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET)
.path("/NORAD/elements/gp.php")
.query_param("NAME", "ISS");
then.status(200).body(
r#"[{
"OBJECT_NAME": "ISS (ZARYA)",
"NORAD_CAT_ID": "25544"
}]"#,
);
});
let client = CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0);
let records = client.get_gp_by_name("ISS").unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].object_name.as_deref(), Some("ISS (ZARYA)"));
}
#[test]
fn test_get_gp_by_intdes() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET)
.path("/NORAD/elements/gp.php")
.query_param("INTDES", "1998-067A");
then.status(200).body(
r#"[{
"OBJECT_NAME": "ISS (ZARYA)",
"NORAD_CAT_ID": "25544"
}]"#,
);
});
let client = CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0);
let records = client.get_gp_by_intdes("1998-067A").unwrap();
assert_eq!(records.len(), 1);
}
#[test]
fn test_get_sup_gp() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET)
.path("/NORAD/elements/supplemental/sup-gp.php")
.query_param("SOURCE", "spacex");
then.status(200).body(
r#"[{
"OBJECT_NAME": "STARLINK-1234",
"NORAD_CAT_ID": "44000"
}]"#,
);
});
let client = CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0);
let records = client.get_sup_gp(SupGPSource::SpaceX).unwrap();
assert_eq!(records.len(), 1);
}
#[test]
fn test_get_satcat_by_catnr() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET)
.path("/satcat/records.php")
.query_param("CATNR", "25544");
then.status(200).body(
r#"[{
"OBJECT_NAME": "ISS (ZARYA)",
"NORAD_CAT_ID": "25544",
"OBJECT_TYPE": "PAY"
}]"#,
);
});
let client = CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0);
let records = client.get_satcat_by_catnr(25544).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].norad_cat_id, Some(25544));
}
#[test]
fn test_get_sgp_propagator_by_catnr_empty_results() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET)
.path("/NORAD/elements/gp.php")
.query_param("CATNR", "99999");
then.status(200).body("[]");
});
let client = CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0);
let result = client.get_sgp_propagator_by_catnr(99999, 60.0);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("No GP records found")
);
}
#[test]
fn test_retry_on_503() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/NORAD/elements/gp.php");
then.status(503);
});
let client =
CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0).max_retries(1);
let query = CelestrakQuery::gp().group("stations");
let result = client.query_raw(&query);
assert!(result.is_err());
mock.assert_calls(2); }
#[test]
fn test_no_retry_on_404() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/NORAD/elements/gp.php");
then.status(404);
});
let client =
CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0).max_retries(3);
let query = CelestrakQuery::gp().group("nonexistent");
let result = client.query_raw(&query);
assert!(result.is_err());
mock.assert_calls(1); }
#[test]
fn test_max_retries_zero_no_retry() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/NORAD/elements/gp.php");
then.status(503);
});
let client =
CelestrakClient::with_base_url_and_cache_age(&server.base_url(), 0.0).max_retries(0);
let query = CelestrakQuery::gp().group("stations");
let result = client.query_raw(&query);
assert!(result.is_err());
mock.assert_calls(1); }
#[test]
fn test_max_retries_builder() {
let client = CelestrakClient::new().max_retries(5);
assert_eq!(client.max_retries, 5);
let client = CelestrakClient::new().max_retries(0);
assert_eq!(client.max_retries, 0);
}
#[test]
#[cfg_attr(not(feature = "ci"), ignore)]
#[serial_test::serial]
fn test_integration_gp_by_group() {
let client = CelestrakClient::with_cache_age(0.0);
let records = client.get_gp_by_group("stations").expect("GP query failed");
assert!(!records.is_empty(), "Expected at least one GP record");
}
#[test]
#[cfg_attr(not(feature = "ci"), ignore)]
#[serial_test::serial]
fn test_integration_gp_by_catnr() {
let client = CelestrakClient::with_cache_age(0.0);
let records = client.get_gp_by_catnr(25544).expect("GP query failed");
assert!(!records.is_empty(), "Expected ISS GP record");
assert_eq!(records[0].norad_cat_id, Some(25544));
}
#[test]
#[cfg_attr(not(feature = "ci"), ignore)]
#[serial_test::serial]
fn test_integration_gp_by_name() {
let client = CelestrakClient::with_cache_age(0.0);
let records = client.get_gp_by_name("ISS").expect("GP query failed");
assert!(
!records.is_empty(),
"Expected at least one record matching ISS"
);
}
#[test]
#[cfg_attr(not(feature = "ci"), ignore)]
#[serial_test::serial]
fn test_integration_satcat() {
let client = CelestrakClient::with_cache_age(0.0);
let records = client
.get_satcat_by_catnr(25544)
.expect("SATCAT query failed");
assert!(!records.is_empty(), "Expected ISS SATCAT record");
assert_eq!(records[0].norad_cat_id, Some(25544));
}
#[test]
#[cfg_attr(not(feature = "ci"), ignore)]
#[serial_test::serial]
fn test_integration_get_sgp_propagator_by_catnr() {
let client = CelestrakClient::with_cache_age(0.0);
let propagator = client
.get_sgp_propagator_by_catnr(25544, 60.0)
.expect("SGP propagator creation failed");
assert_eq!(propagator.norad_id, 25544);
}
}