#![cfg(feature = "async")]
use opencellid::{
ApiErrorCode, AreaQuery, Bbox, CellKey, Client, ClientBuilder, DumpKind, Error,
GetCellsInAreaParams, Measurement, Radio,
};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn client(server: &MockServer) -> Client {
ClientBuilder::new()
.api_key("test_key")
.base_url(format!("{}/", server.uri()))
.unwrap()
.build()
.unwrap()
}
#[tokio::test]
async fn get_cell_returns_parsed_record() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/cell/get"))
.and(query_param("key", "test_key"))
.and(query_param("mcc", "250"))
.and(query_param("cellid", "42"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"lat":55.7,"lon":37.6,"mcc":250,"mnc":1,"lac":7,"cellid":42,"range":1000,"samples":12,"changeable":1,"averageSignalStrength":-95,"radio":"LTE"}"#,
))
.mount(&server)
.await;
let cell = client(&server)
.get_cell(CellKey::new(250, 1, 7, 42).with_radio(Radio::Lte))
.await
.expect("call ok");
assert_eq!(cell.cell_id, 42);
assert_eq!(cell.radio, Some(Radio::Lte));
assert_eq!(cell.avg_signal, -95);
assert!(cell.changeable);
}
#[tokio::test]
async fn get_cell_maps_api_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/cell/get"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"{"err":2,"msg":"invalid api key"}"#),
)
.mount(&server)
.await;
let err = client(&server)
.get_cell(CellKey::new(250, 1, 7, 42))
.await
.unwrap_err();
match err {
Error::Api { code, message } => {
assert_eq!(code, ApiErrorCode::InvalidApiKey);
assert_eq!(message, "invalid api key");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn get_cell_maps_http_429() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/cell/get"))
.respond_with(ResponseTemplate::new(429).set_body_string("daily limit"))
.mount(&server)
.await;
let err = client(&server)
.get_cell(CellKey::new(250, 1, 7, 42))
.await
.unwrap_err();
match err {
Error::Api { code, .. } => assert_eq!(code, ApiErrorCode::DailyLimitExceeded),
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn get_cell_truncates_large_error_body() {
let server = MockServer::start().await;
let big = "x".repeat(2000);
Mock::given(method("GET"))
.and(path("/cell/get"))
.respond_with(ResponseTemplate::new(500).set_body_string(big))
.mount(&server)
.await;
let err = client(&server)
.get_cell(CellKey::new(250, 1, 7, 42))
.await
.unwrap_err();
match err {
Error::Api { message, .. } => {
assert!(message.len() < 1024);
assert!(message.contains("(2000 bytes total)"));
}
_ => panic!(),
}
}
#[tokio::test]
async fn get_cells_in_area_size_returns_count() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/cell/getInAreaSize"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"count":17}"#))
.mount(&server)
.await;
let bbox = Bbox::new(55.0, 37.0, 56.0, 38.0).unwrap();
let count = client(&server)
.get_cells_in_area_size(AreaQuery::new(bbox).mcc(250))
.await
.unwrap();
assert_eq!(count.count, 17);
}
#[cfg(feature = "csv")]
#[tokio::test]
async fn get_cells_in_area_parses_csv() {
let server = MockServer::start().await;
let csv = "lat,lon,mcc,mnc,lac,cellid,averageSignalStrength,range,samples,changeable,radio\n\
55.7,37.6,250,1,7,42,-95,1000,12,1,LTE\n\
55.8,37.7,250,2,8,43,-100,500,5,0,GSM\n";
Mock::given(method("GET"))
.and(path("/cell/getInArea"))
.respond_with(ResponseTemplate::new(200).set_body_string(csv))
.mount(&server)
.await;
let bbox = Bbox::new(55.0, 37.0, 56.0, 38.0).unwrap();
let params = GetCellsInAreaParams::new(AreaQuery::new(bbox)).limit(10);
let cells = client(&server).get_cells_in_area(params).await.unwrap();
assert_eq!(cells.len(), 2);
assert_eq!(cells[0].radio, Some(Radio::Lte));
assert_eq!(cells[1].cell_id, 43);
}
#[tokio::test]
async fn add_measurement_sends_expected_query() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/measure/add"))
.and(query_param("key", "test_key"))
.and(query_param("act", "LTE"))
.and(query_param("mcc", "250"))
.and(query_param("cellid", "42"))
.and(query_param("signal", "-95"))
.and(query_param("rating", "50"))
.respond_with(ResponseTemplate::new(200).set_body_string("0,OK"))
.mount(&server)
.await;
let m = Measurement::new(55.7558, 37.6173, 250, 1, 7, 42, Radio::Lte)
.unwrap()
.with_signal(-95)
.with_rating(50);
client(&server).add_measurement(&m).await.expect("submit ok");
}
#[tokio::test]
async fn upload_csv_oversized_fails_before_request() {
use opencellid::Error;
let server = MockServer::start().await;
let big = vec![0u8; 2 * 1024 * 1024 + 1];
let err = client(&server).upload_csv(big).await.unwrap_err();
assert!(matches!(err, Error::InvalidInput(_)), "got {err:?}");
}
#[tokio::test]
async fn upload_response_strict_ok_check() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/measure/uploadCsv"))
.respond_with(ResponseTemplate::new(200).set_body_string("NOTOK"))
.mount(&server)
.await;
let err = client(&server)
.upload_csv(b"mcc,mnc,lac,cellid,lon,lat\n250,1,7,42,37.6,55.7\n".to_vec())
.await
.unwrap_err();
match err {
Error::Parse(_) => {}
other => panic!("expected Parse error, got {other:?}"),
}
}
fn gzip_payload(content: &[u8]) -> Vec<u8> {
let mut v = vec![0x1F, 0x8B, 0x08, 0x00, 0, 0, 0, 0, 0, 0xFF];
v.extend_from_slice(content);
v
}
#[tokio::test]
async fn download_dump_streams_gzip_to_writer() {
let server = MockServer::start().await;
let body = gzip_payload(b"some-fake-gzip-payload");
let body_len = body.len();
Mock::given(method("GET"))
.and(path("/ocid/downloads"))
.and(query_param("token", "test_key"))
.and(query_param("type", "full"))
.and(query_param("file", "cell_towers.csv.gz"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(body)
.insert_header("Content-Type", "application/gzip"),
)
.mount(&server)
.await;
let mut sink: Vec<u8> = Vec::new();
let n = client(&server)
.download_dump(DumpKind::World, &mut sink)
.await
.expect("download ok");
assert_eq!(n, body_len as u64);
assert!(sink.starts_with(&[0x1F, 0x8B]));
}
#[tokio::test]
async fn download_dump_invalid_token() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/ocid/downloads"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"status":"error","message":"INVALID_TOKEN"}"#,
))
.mount(&server)
.await;
let mut sink: Vec<u8> = Vec::new();
let err = client(&server)
.download_dump(DumpKind::World, &mut sink)
.await
.unwrap_err();
assert!(matches!(err, Error::Api { code: ApiErrorCode::InvalidApiKey, .. }));
assert!(sink.is_empty());
}
#[tokio::test]
async fn download_dump_rate_limited() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/ocid/downloads"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"status":"error","message":"RATE_LIMITED"}"#,
))
.mount(&server)
.await;
let mut sink: Vec<u8> = Vec::new();
let err = client(&server)
.download_dump(DumpKind::Country(437), &mut sink)
.await
.unwrap_err();
assert!(matches!(err, Error::Api { code: ApiErrorCode::TooManyRequests, .. }));
}
#[tokio::test]
async fn download_dump_rejects_non_gzip_non_json_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/ocid/downloads"))
.respond_with(ResponseTemplate::new(200).set_body_string("plain text"))
.mount(&server)
.await;
let mut sink: Vec<u8> = Vec::new();
let err = client(&server)
.download_dump(DumpKind::World, &mut sink)
.await
.unwrap_err();
assert!(matches!(err, Error::Parse(_)), "got {err:?}");
}
#[tokio::test]
async fn download_dump_rejects_invalid_daily_date() {
let server = MockServer::start().await;
let mut sink: Vec<u8> = Vec::new();
let err = client(&server)
.download_dump(DumpKind::Daily { date_utc: "not-a-date".into() }, &mut sink)
.await
.unwrap_err();
assert!(matches!(err, Error::InvalidInput(_)), "got {err:?}");
}
#[tokio::test]
async fn list_daily_diffs_parses_html() {
let server = MockServer::start().await;
let html = r#"
<html><body>
<a href="?token=X&type=diff&file=OCID-diff-cell-export-2026-05-09-T000000.csv.gz">9</a>
<a href="?token=X&type=diff&file=OCID-diff-cell-export-2026-05-10-T000000.csv.gz">10</a>
</body></html>
"#;
Mock::given(method("GET"))
.and(path("/downloads.php"))
.and(query_param("token", "test_key"))
.respond_with(ResponseTemplate::new(200).set_body_string(html))
.mount(&server)
.await;
let listings = client(&server).list_daily_diffs().await.unwrap();
assert_eq!(listings.len(), 2);
assert_eq!(listings[0].date_utc, "2026-05-09");
assert_eq!(
listings[1].filename,
"OCID-diff-cell-export-2026-05-10-T000000.csv.gz"
);
}
#[tokio::test]
async fn download_dump_to_path_atomic_write() {
use std::path::PathBuf;
let server = MockServer::start().await;
let body = gzip_payload(b"payload");
Mock::given(method("GET"))
.and(path("/ocid/downloads"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(body)
.insert_header("Content-Type", "application/gzip"),
)
.mount(&server)
.await;
let dir = std::env::temp_dir().join(format!(
"opencellid-test-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let final_path: PathBuf = dir.join("dump.csv.gz");
let part_path: PathBuf = dir.join("dump.csv.gz.part");
let _ = std::fs::remove_file(&final_path);
let _ = std::fs::remove_file(&part_path);
let n = client(&server)
.download_dump_to_path(DumpKind::World, &final_path)
.await
.expect("download ok");
assert!(n > 0);
assert!(final_path.exists(), "final file missing");
assert!(!part_path.exists(), ".part still present after success");
let _ = std::fs::remove_file(&final_path);
let _ = std::fs::remove_dir(&dir);
}
#[tokio::test]
async fn download_dump_to_path_cleans_up_on_error() {
use std::path::PathBuf;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/ocid/downloads"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"status":"error","message":"INVALID_TOKEN"}"#,
))
.mount(&server)
.await;
let dir = std::env::temp_dir().join(format!(
"opencellid-test-cleanup-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let final_path: PathBuf = dir.join("dump.csv.gz");
let part_path: PathBuf = dir.join("dump.csv.gz.part");
let _ = std::fs::remove_file(&final_path);
let _ = std::fs::remove_file(&part_path);
let err = client(&server)
.download_dump_to_path(DumpKind::World, &final_path)
.await
.unwrap_err();
assert!(matches!(err, Error::Api { code: ApiErrorCode::InvalidApiKey, .. }));
assert!(!final_path.exists(), "final file created on error");
assert!(!part_path.exists(), ".part not cleaned up");
let _ = std::fs::remove_dir(&dir);
}
#[cfg(feature = "blocking")]
#[test]
fn blocking_download_dump_streams_gzip() {
use opencellid::BlockingClient;
let rt = tokio::runtime::Runtime::new().unwrap();
let server = rt.block_on(MockServer::start());
let body = gzip_payload(b"some-fake-gzip-payload");
let body_len = body.len();
rt.block_on(
Mock::given(method("GET"))
.and(path("/ocid/downloads"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(body)
.insert_header("Content-Type", "application/gzip"),
)
.mount(&server),
);
let client: BlockingClient = ClientBuilder::new()
.api_key("test_key")
.base_url(format!("{}/", server.uri()))
.unwrap()
.build_blocking()
.unwrap();
let mut sink: Vec<u8> = Vec::new();
let n = client
.download_dump(DumpKind::Country(437), &mut sink)
.expect("blocking download ok");
assert_eq!(n, body_len as u64);
assert!(sink.starts_with(&[0x1F, 0x8B]));
}
#[cfg(feature = "blocking")]
#[test]
fn blocking_download_dump_invalid_token() {
use opencellid::BlockingClient;
let rt = tokio::runtime::Runtime::new().unwrap();
let server = rt.block_on(MockServer::start());
rt.block_on(
Mock::given(method("GET"))
.and(path("/ocid/downloads"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"status":"error","message":"INVALID_TOKEN"}"#,
))
.mount(&server),
);
let client: BlockingClient = ClientBuilder::new()
.api_key("test_key")
.base_url(format!("{}/", server.uri()))
.unwrap()
.build_blocking()
.unwrap();
let mut sink: Vec<u8> = Vec::new();
let err = client
.download_dump(DumpKind::World, &mut sink)
.unwrap_err();
assert!(matches!(err, Error::Api { code: ApiErrorCode::InvalidApiKey, .. }));
}
#[cfg(feature = "blocking")]
#[test]
fn blocking_get_cell_round_trip() {
let rt = tokio::runtime::Runtime::new().unwrap();
let server = rt.block_on(MockServer::start());
rt.block_on(
Mock::given(method("GET"))
.and(path("/cell/get"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"lat":1.0,"lon":2.0,"mcc":250,"mnc":1,"lac":7,"cellid":42,"range":0,"samples":0,"changeable":0,"averageSignalStrength":0}"#,
))
.mount(&server),
);
let client = ClientBuilder::new()
.api_key("k")
.base_url(format!("{}/", server.uri()))
.unwrap()
.build_blocking()
.unwrap();
let cell = client.get_cell(CellKey::new(250, 1, 7, 42)).unwrap();
assert_eq!(cell.cell_id, 42);
}