use flightradarapi::{
AirportDetailsOptions, ClientConfig, Countries, FlightQuery, FlightRadarApi, FlightRadarClient,
FlightRadarError, FlightTrackerConfig, HistoryFileType, Pagination, PresetZone,
};
use serde_json::{Value, json};
use std::time::Duration;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path, query_param},
};
#[tokio::test]
async fn get_json_returns_payload_on_success() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/common/v1/search.json"))
.and(query_param("query", "afr"))
.and(query_param("limit", "5"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"results": [
{"type": "live", "id": "AFR123"}
]
})))
.mount(&server)
.await;
let config = ClientConfig::with_base_url(server.uri())
.expect("valid base URL")
.set_timeout(Duration::from_secs(2))
.set_retry_attempts(0);
let api = FlightRadarApi::with_config(config).expect("valid api config");
let payload = api.search("afr", 5).await.expect("search should succeed");
assert_eq!(payload.items().len(), 1);
}
#[tokio::test]
async fn retries_then_succeeds_on_server_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/common/v1/flight/list.json"))
.respond_with(
ResponseTemplate::new(500)
.set_body_string("temporary")
.append_header("content-type", "text/plain"),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/common/v1/flight/list.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"flights": [
{"id": "abc", "callsign": "AFR123", "lat": 48.0, "lon": 2.0}
]
})))
.mount(&server)
.await;
let config = ClientConfig::with_base_url(server.uri())
.expect("valid base URL")
.set_timeout(Duration::from_secs(2))
.set_retry_attempts(1);
let api = FlightRadarApi::with_config(config).expect("valid api config");
let payload = api
.get_flights(&FlightQuery::default())
.await
.expect("flight list should succeed after retry");
assert_eq!(payload.items().len(), 1);
assert_eq!(payload.items()[0].callsign.as_deref(), Some("AFR123"));
}
#[tokio::test]
async fn status_520_maps_to_cloudflare_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/common/v1/airport.json"))
.respond_with(ResponseTemplate::new(520).set_body_string("blocked"))
.mount(&server)
.await;
let config = ClientConfig::with_base_url(server.uri())
.expect("valid base URL")
.set_retry_attempts(0);
let client = FlightRadarClient::new(config).expect("valid client");
let result: Result<Value, FlightRadarError> = client
.get_json("common/v1/airport.json", &[("code", "CDG".to_owned())])
.await;
match result {
Err(FlightRadarError::Cloudflare { status, .. }) => assert_eq!(status, 520),
other => panic!("unexpected result: {other:?}"),
}
}
#[tokio::test]
async fn airport_response_maps_typed_payload() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/common/v1/airport.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"airport": {
"iata": "CDG",
"icao": "LFPG",
"name": "Paris Charles de Gaulle",
"latitude": 49.0097,
"longitude": 2.5479
}
})))
.mount(&server)
.await;
let config = ClientConfig::with_base_url(server.uri())
.expect("valid base URL")
.set_retry_attempts(0);
let api = FlightRadarApi::with_config(config).expect("valid api config");
let payload = api
.get_airport("cdg")
.await
.expect("airport endpoint should parse");
let airport = payload.airport().expect("airport should be present");
assert_eq!(airport.iata.as_deref(), Some("CDG"));
assert_eq!(airport.icao.as_deref(), Some("LFPG"));
}
#[tokio::test]
async fn typed_endpoints_support_nested_result_response_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/common/v1/search.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"result": {
"response": {
"results": [{"type": "airport", "id": "CDG"}]
}
}
})))
.mount(&server)
.await;
let config = ClientConfig::with_base_url(server.uri())
.expect("valid base URL")
.set_retry_attempts(0);
let api = FlightRadarApi::with_config(config).expect("valid api config");
let payload = api.search("cdg", 3).await.expect("search should parse");
assert_eq!(payload.items().len(), 1);
}
#[tokio::test]
async fn typed_endpoint_returns_invalid_payload_for_unknown_schema() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/common/v1/flight/list.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"unexpected": {"shape": true}
})))
.mount(&server)
.await;
let config = ClientConfig::with_base_url(server.uri())
.expect("valid base URL")
.set_retry_attempts(0);
let api = FlightRadarApi::with_config(config).expect("valid api config");
let result = api.get_flights(&FlightQuery::default()).await;
match result {
Err(FlightRadarError::InvalidPayload(_)) => {}
other => panic!("expected InvalidPayload, got {other:?}"),
}
}
#[tokio::test]
async fn login_and_logout_use_post_form() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/user/login"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"success": true,
"message": "ok"
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/user/logout"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"status": "success"
})))
.mount(&server)
.await;
let config = ClientConfig::with_base_url(server.uri())
.expect("valid base URL")
.set_retry_attempts(0);
let api = FlightRadarApi::with_config(config).expect("valid api config");
let login = api
.login("test@example.com", "secret")
.await
.expect("login should succeed");
assert!(login.is_success());
let logout = api.logout().await.expect("logout should succeed");
assert!(logout.is_success());
}
#[tokio::test]
async fn login_returns_login_error_when_rejected() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/user/login"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"success": false,
"message": "invalid credentials"
})))
.mount(&server)
.await;
let config = ClientConfig::with_base_url(server.uri())
.expect("valid base URL")
.set_retry_attempts(0);
let api = FlightRadarApi::with_config(config).expect("valid api config");
let result = api.login("bad@example.com", "wrong").await;
match result {
Err(FlightRadarError::LoginError { message }) => {
assert_eq!(message, "invalid credentials")
}
other => panic!("expected LoginError, got {other:?}"),
}
}
#[tokio::test]
async fn airlines_and_airports_and_flight_details_parse_typed() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/common/v1/airline/list.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"airlines": [
{"icao": "AFR", "iata": "AF", "name": "Air France"}
]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/common/v1/airport/list.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"airports": [
{"iata": "CDG", "icao": "LFPG", "name": "Paris Charles de Gaulle"}
]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/common/v1/flight/details.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"flight": {"flight_id": "abc123", "status": "en-route"}
})))
.mount(&server)
.await;
let config = ClientConfig::with_base_url(server.uri())
.expect("valid base URL")
.set_retry_attempts(0);
let api = FlightRadarApi::with_config(config).expect("valid api config");
let airlines = api.get_airlines().await.expect("airlines should parse");
assert_eq!(airlines.items().len(), 1);
assert_eq!(airlines.items()[0].icao.as_deref(), Some("AFR"));
let airports = api
.get_airports(&["france".to_owned()])
.await
.expect("airports should parse");
assert_eq!(airports.items().len(), 1);
assert_eq!(airports.items()[0].iata.as_deref(), Some("CDG"));
let details = api
.get_flight_details("abc123")
.await
.expect("details should parse");
assert_eq!(
details.details().and_then(|d| d.status.as_deref()),
Some("en-route")
);
}
#[tokio::test]
async fn airport_not_found_is_mapped_to_domain_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/common/v1/airport.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"airport": null
})))
.mount(&server)
.await;
let config = ClientConfig::with_base_url(server.uri())
.expect("valid base URL")
.set_retry_attempts(0);
let api = FlightRadarApi::with_config(config).expect("valid api config");
let result = api.get_airport("ZZZ").await;
match result {
Err(FlightRadarError::AirportNotFound { code }) => assert_eq!(code, "ZZZ"),
other => panic!("expected AirportNotFound, got {other:?}"),
}
}
#[tokio::test]
async fn flight_tracker_config_is_applied_to_flight_query() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/common/v1/flight/list.json"))
.and(query_param("limit", "50"))
.and(query_param("faa", "0"))
.and(query_param("satellite", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"flights": []
})))
.mount(&server)
.await;
let config = ClientConfig::with_base_url(server.uri())
.expect("valid base URL")
.set_retry_attempts(0);
let mut api = FlightRadarApi::with_config(config).expect("valid api config");
api.set_flight_tracker_config(FlightTrackerConfig {
faa: false,
satellite: false,
mlat: true,
flarm: true,
adsb: true,
gnd: true,
air: true,
vehicles: true,
estimated: true,
maxage: 14_400,
gliders: true,
stats: true,
limit: 50,
});
let _ = api
.get_flights(&FlightQuery::default())
.await
.expect("flights should parse");
}
#[tokio::test]
async fn countries_enum_is_supported_for_airports() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/common/v1/airport/list.json"))
.and(query_param("countries", "france,united-states"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"airports": [
{"iata": "CDG", "icao": "LFPG", "name": "Paris Charles de Gaulle"}
]
})))
.mount(&server)
.await;
let config = ClientConfig::with_base_url(server.uri())
.expect("valid base URL")
.set_retry_attempts(0);
let api = FlightRadarApi::with_config(config).expect("valid api config");
let airports = api
.get_airports_by_countries(&[Countries::France, Countries::UnitedStates])
.await
.expect("airports should parse");
assert_eq!(airports.items().len(), 1);
}
#[tokio::test]
async fn airport_details_disruptions_and_most_tracked_parse_typed() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/common/v1/airport/details.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"airport": {"iata": "CDG", "icao": "LFPG"},
"flights": [{"id": "AFR6", "callsign": "AFR6"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/common/v1/airport/disruptions.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"disruptions": [{"title": "Strike", "level": "high"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/common/v1/most-tracked.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"flights": [{"flight_id": "ABC123", "watchers": 1200}]
})))
.mount(&server)
.await;
let config = ClientConfig::with_base_url(server.uri())
.expect("valid base URL")
.set_retry_attempts(0);
let api = FlightRadarApi::with_config(config).expect("valid api config");
let details = api
.get_airport_details("cdg", 20, 1)
.await
.expect("airport details should parse");
assert_eq!(details.airport.and_then(|a| a.iata).as_deref(), Some("CDG"));
let disruptions = api
.get_airport_disruptions("cdg")
.await
.expect("airport disruptions should parse");
assert_eq!(disruptions.items().len(), 1);
let most_tracked = api
.get_most_tracked(10)
.await
.expect("most tracked should parse");
assert_eq!(most_tracked.items().len(), 1);
}
#[tokio::test]
async fn history_and_assets_endpoints_return_text_and_bytes() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/common/v1/flight/history-file"))
.respond_with(ResponseTemplate::new(200).set_body_string("timestamp,lat,lon"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/static/images/data/operators/AFR.logo0.png"))
.respond_with(ResponseTemplate::new(200).set_body_string("PNGDATA"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/static/images/data/flags-small/france.png"))
.respond_with(ResponseTemplate::new(200).set_body_string("FLAG"))
.mount(&server)
.await;
let config = ClientConfig::with_base_url(server.uri())
.expect("valid base URL")
.set_retry_attempts(0);
let api = FlightRadarApi::with_config(config).expect("valid api config");
let history = api
.get_history_data_typed("ABC123", HistoryFileType::Csv, 1_700_000_000)
.await
.expect("history data should return text");
assert!(history.contains("timestamp"));
let logo = api
.get_airline_logo("AFR")
.await
.expect("airline logo should return bytes");
assert!(!logo.is_empty());
let flag = api
.get_country_flag("france")
.await
.expect("country flag should return bytes");
assert!(!flag.is_empty());
}
#[tokio::test]
async fn advanced_pagination_options_are_supported() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/common/v1/most-tracked.json"))
.and(query_param("limit", "20"))
.and(query_param("page", "3"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"flights": [{"flight_id": "X1"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/common/v1/airport/details.json"))
.and(query_param("flight_limit", "15"))
.and(query_param("page", "2"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"airport": {"iata": "CDG"}
})))
.mount(&server)
.await;
let config = ClientConfig::with_base_url(server.uri())
.expect("valid base URL")
.set_retry_attempts(0);
let api = FlightRadarApi::with_config(config).expect("valid api config");
let most_tracked = api
.get_most_tracked_with_pagination(Pagination { limit: 20, page: 3 })
.await
.expect("most tracked pagination should parse");
assert_eq!(most_tracked.items().len(), 1);
let details = api
.get_airport_details_with_options(
"CDG",
AirportDetailsOptions {
flight_limit: 15,
page: 2,
},
)
.await
.expect("airport details options should parse");
assert_eq!(details.airport.and_then(|a| a.iata).as_deref(), Some("CDG"));
}
#[test]
fn get_bounds_by_point_calculates_haversine_bounds() {
let bounds = FlightRadarApi::get_bounds_by_point(48.8566, 2.3522, 10.0)
.expect("bounds calculation should succeed");
let parts: Vec<&str> = bounds.split(',').collect();
assert_eq!(parts.len(), 4, "bounds should have 4 coordinates");
let lat_min: f64 = parts[0].parse().expect("lat_min should be f64");
let lon_min: f64 = parts[1].parse().expect("lon_min should be f64");
let lat_max: f64 = parts[2].parse().expect("lat_max should be f64");
let lon_max: f64 = parts[3].parse().expect("lon_max should be f64");
assert!(lat_min < 48.8566, "lat_min should be below center");
assert!(lat_max > 48.8566, "lat_max should be above center");
assert!(lon_min < 2.3522, "lon_min should be left of center");
assert!(lon_max > 2.3522, "lon_max should be right of center");
assert!(
(lat_max - lat_min) < 0.2,
"latitude span should be reasonable"
);
assert!(
(lon_max - lon_min) < 0.3,
"longitude span should account for latitude projection"
);
}
#[test]
fn get_bounds_by_point_rejects_invalid_inputs() {
assert!(FlightRadarApi::get_bounds_by_point(91.0, 0.0, 10.0).is_err());
assert!(FlightRadarApi::get_bounds_by_point(-91.0, 0.0, 10.0).is_err());
assert!(FlightRadarApi::get_bounds_by_point(0.0, 181.0, 10.0).is_err());
assert!(FlightRadarApi::get_bounds_by_point(0.0, -181.0, 10.0).is_err());
assert!(FlightRadarApi::get_bounds_by_point(0.0, 0.0, -5.0).is_err());
assert!(FlightRadarApi::get_bounds_by_point(0.0, 0.0, 0.0).is_err());
}
#[test]
fn flight_utility_methods_extract_from_extra_hashmap() {
use flightradarapi::Flight;
use serde_json::json;
let mut flight = Flight {
id: Some("ABC123".to_string()),
callsign: Some("AFR456".to_string()),
latitude: Some(48.8566),
longitude: Some(2.3522),
airline_icao: Some("AFR".to_string()),
origin_airport_iata: Some("CDG".to_string()),
destination_airport_iata: Some("LHR".to_string()),
..Default::default()
};
flight.extra.insert("altitude".to_string(), json!(35000));
flight.extra.insert("heading".to_string(), json!(90.5));
flight
.extra
.insert("ground_speed".to_string(), json!(450.0));
flight
.extra
.insert("vertical_speed".to_string(), json!(1200.0));
flight
.extra
.insert("aircraft_type".to_string(), json!("A380"));
flight
.extra
.insert("registration".to_string(), json!("F-HPJE"));
assert_eq!(flight.get_altitude(), Some(35000));
assert_eq!(flight.get_heading(), Some(90.5));
assert_eq!(flight.get_ground_speed(), Some(450.0));
assert_eq!(flight.get_vertical_speed(), Some(1200.0));
assert_eq!(flight.get_aircraft_type(), Some("A380".to_string()));
assert_eq!(flight.get_registration(), Some("F-HPJE".to_string()));
let distance = flight.get_distance_from(48.9, 2.4);
assert!(distance.is_some());
assert!(distance.unwrap() > 0.0);
}
#[test]
fn airport_utility_methods_extract_from_extra_hashmap() {
use flightradarapi::Airport;
use serde_json::json;
let mut airport = Airport {
iata: Some("CDG".to_string()),
icao: Some("LFPG".to_string()),
name: Some("Paris Charles de Gaulle".to_string()),
latitude: Some(48.841389),
longitude: Some(2.393333),
country: Some("France".to_string()),
..Default::default()
};
airport
.extra
.insert("timezone".to_string(), json!("Europe/Paris"));
airport.extra.insert("elevation".to_string(), json!(392));
airport
.extra
.insert("runways".to_string(), json!("L/R, L/C/R"));
airport.extra.insert("city".to_string(), json!("Paris"));
assert_eq!(airport.get_timezone(), Some("Europe/Paris".to_string()));
assert_eq!(airport.get_elevation(), Some(392));
assert_eq!(airport.get_runways(), Some("L/R, L/C/R".to_string()));
assert_eq!(airport.get_city(), Some("Paris".to_string()));
let distance = airport.get_distance_from(48.9, 2.4);
assert!(distance.is_some());
assert!(distance.unwrap() > 0.0);
}
#[test]
fn preset_zones_generate_valid_bounds() {
let zones = vec![
PresetZone::Europe,
PresetZone::NorthAmerica,
PresetZone::SouthAmerica,
PresetZone::MiddleEastCentralAsia,
PresetZone::SoutheastAsia,
PresetZone::EastAsia,
PresetZone::AustraliaOceania,
PresetZone::Africa,
];
for zone in zones {
let bounds = zone.get_bounds();
let parts: Vec<&str> = bounds.split(',').collect();
assert_eq!(
parts.len(),
4,
"zone {} bounds should have 4 coordinates",
zone.name()
);
for (idx, part) in parts.iter().enumerate() {
let msg = format!("zone {} coordinate {} should be f64", zone.name(), idx);
part.parse::<f64>().expect(&msg);
}
assert!(!zone.name().is_empty());
}
}
#[test]
fn preset_zone_custom_creates_custom_bounds() {
let custom = PresetZone::Custom(40.0, -50.0, 60.0, -20.0);
assert_eq!(custom.get_bounds(), "40.00,-50.00,60.00,-20.00");
assert_eq!(custom.name(), "Custom");
}
#[tokio::test]
async fn volcanic_eruptions_and_bookmarks_are_premium_endpoints() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/common/v1/volcanic-eruptions.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"events": [
{"id": "mount-fuji", "name": "Mount Fuji", "latitude": 35.36, "longitude": 138.73, "status": "active"}
]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/common/v1/bookmarks.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"bookmarks": [
{"id": "bm1", "flight_id": "afr6", "callsign": "AFR6", "aircraft_type": "A380"}
]
})))
.mount(&server)
.await;
let config = ClientConfig::with_base_url(server.uri())
.expect("valid base URL")
.set_retry_attempts(0);
let api = FlightRadarApi::with_config(config).expect("valid api config");
let eruptions = api
.get_volcanic_eruption_data()
.await
.expect("volcanic data should parse");
assert_eq!(eruptions.items().len(), 1);
assert_eq!(eruptions.items()[0].name.as_deref(), Some("Mount Fuji"));
let bookmarks = api.get_bookmarks().await.expect("bookmarks should parse");
assert_eq!(bookmarks.items().len(), 1);
assert_eq!(bookmarks.items()[0].callsign.as_deref(), Some("AFR6"));
}