Skip to content

Commit 85a3f7e

Browse files
Eric Pricegimballock
authored andcommitted
Add integration and unit tests for monitoring JSON API endpoints
Closes #329 Exercise every JSON REST API endpoint against live SV2 topologies and strengthen unit-test coverage for edge cases. Integration tests (monitoring_integration.rs): - 15 new tests covering /, /api/v1/global, /api/v1/server, /api/v1/server/channels, /api/v1/clients, /api/v1/clients/{id}, /api/v1/clients/{id}/channels, /api/v1/sv1/clients, and /api/v1/sv1/clients/{id} for both Pool and tProxy topologies - Topology setup helpers (PoolWithSv2Miner, TproxyWithSv1Miner) eliminate duplicated boilerplate across tests - 404 endpoints assert both HTTP status code and JSON error body via new assert_api_not_found helper Assertion helpers (prometheus_metrics_assertions.rs): - fetch_api_json: parse JSON API response - fetch_api_with_status: return (status_code, json) without panicking on non-2xx, enabling 404 testing - poll_until_api_field_gte: poll JSON endpoint until a field reaches a threshold (analogous to poll_until_metric_gte for Prometheus) - assert_api_root, assert_api_global: reusable structure checks - assert_api_not_found: verify HTTP 404 + error field - POLL_TIMEOUT constant to reduce repetition HTTP helper (utils.rs): - make_get_request_with_status: returns (status, body) without panicking on 4xx responses, unlike make_get_request Unit tests (http_server.rs): - 11 new tests for pagination boundaries (limit=0, offset beyond total, limit exceeding MAX_LIMIT), missing data sources returning 404, invalid/non-existent client IDs, SV1 data in global endpoint, and Prometheus metrics with no sources Dependencies: - Add serde_json to integration-tests for JSON parsing
1 parent a0f2703 commit 85a3f7e

6 files changed

Lines changed: 843 additions & 0 deletions

File tree

integration-tests/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

integration-tests/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ tokio-util = { version = "0.7", default-features = false }
2424
tracing = { version = "0.1.41", default-features = false }
2525
tracing-subscriber = { version = "0.3.19", default-features = false }
2626
hex = "0.4.3"
27+
serde_json = "1"
2728

2829
# Direct dependencies kept only for the embedded `mining_device` module.
2930
# Remove this block when removing:

integration-tests/lib/prometheus_metrics_assertions.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
//! exposed by SV2 components during integration tests.
33
44
use std::net::SocketAddr;
5+
use std::time::Duration;
6+
7+
/// Default timeout used when polling for eventually-consistent data.
8+
/// Needs to be generous enough for the monitoring snapshot cache (1s refresh) to populate
9+
/// under CI load, where components may take several seconds to complete handshakes.
10+
pub const POLL_TIMEOUT: Duration = Duration::from_secs(15);
511

612
/// Fetch the raw Prometheus text-format metrics from a component's `/metrics` endpoint.
713
/// Uses `spawn_blocking` to avoid blocking the tokio runtime with synchronous HTTP calls.
@@ -27,6 +33,124 @@ pub async fn fetch_api(monitoring_addr: SocketAddr, path: &str) -> String {
2733
.expect("spawn_blocking for fetch_api panicked")
2834
}
2935

36+
/// Fetch a JSON API endpoint and parse the response into a `serde_json::Value`.
37+
pub async fn fetch_api_json(monitoring_addr: SocketAddr, path: &str) -> serde_json::Value {
38+
let body = fetch_api(monitoring_addr, path).await;
39+
serde_json::from_str(&body).unwrap_or_else(|e| {
40+
panic!(
41+
"Failed to parse JSON from {} response: {}\nBody: {}",
42+
path, e, body
43+
)
44+
})
45+
}
46+
47+
/// Fetch a JSON API endpoint returning both the HTTP status code and parsed JSON body.
48+
/// Unlike `fetch_api_json`, this does **not** panic on non-2xx responses, so it can be
49+
/// used to test error endpoints (e.g. 404).
50+
pub async fn fetch_api_with_status(
51+
monitoring_addr: SocketAddr,
52+
path: &str,
53+
) -> (i32, serde_json::Value) {
54+
let url = format!("http://{}{}", monitoring_addr, path);
55+
tokio::task::spawn_blocking(move || {
56+
let (status, bytes) = crate::utils::http::make_get_request_with_status(&url, 5);
57+
let body = String::from_utf8(bytes).expect("api response should be valid UTF-8");
58+
let json: serde_json::Value = serde_json::from_str(&body).unwrap_or_else(|e| {
59+
panic!(
60+
"Failed to parse JSON from {} (status {}): {}\nBody: {}",
61+
url, status, e, body
62+
)
63+
});
64+
(status, json)
65+
})
66+
.await
67+
.expect("spawn_blocking for fetch_api_with_status panicked")
68+
}
69+
70+
/// Assert that an endpoint returns HTTP 404 with a JSON `{"error": "..."}` body.
71+
pub async fn assert_api_not_found(monitoring_addr: SocketAddr, path: &str) {
72+
let (status, json) = fetch_api_with_status(monitoring_addr, path).await;
73+
assert_eq!(
74+
status, 404,
75+
"{} should return HTTP 404, got {} with body: {}",
76+
path, status, json
77+
);
78+
assert!(
79+
json["error"].is_string(),
80+
"{} should return JSON with 'error' field, got: {}",
81+
path,
82+
json
83+
);
84+
}
85+
86+
/// Poll a JSON API endpoint until a numeric field at `json_pointer` (RFC 6901, e.g.
87+
/// `"/sv2_clients/total_clients"`) reaches `>= min`. Returns the full JSON value once
88+
/// satisfied. Panics if the condition is not met within `timeout`.
89+
///
90+
/// This is the JSON equivalent of `poll_until_metric_gte` — use it for endpoints whose
91+
/// data only appears after the monitoring snapshot cache has refreshed.
92+
pub async fn poll_until_api_field_gte(
93+
monitoring_addr: SocketAddr,
94+
path: &str,
95+
json_pointer: &str,
96+
min: f64,
97+
timeout: std::time::Duration,
98+
) -> serde_json::Value {
99+
let deadline = tokio::time::Instant::now() + timeout;
100+
loop {
101+
// Use fetch_api_with_status so that transient non-2xx responses (e.g. 404
102+
// before the snapshot cache has populated) are retried instead of panicking.
103+
let (status, json) = fetch_api_with_status(monitoring_addr, path).await;
104+
if (200..300).contains(&status) {
105+
if let Some(val) = json.pointer(json_pointer) {
106+
let num = val.as_f64().unwrap_or(0.0);
107+
if num >= min {
108+
return json;
109+
}
110+
}
111+
}
112+
if tokio::time::Instant::now() >= deadline {
113+
panic!(
114+
"JSON field '{}' at {} never reached >= {} within {:?}. Last status: {}. Last response:\n{}",
115+
json_pointer,
116+
path,
117+
min,
118+
timeout,
119+
status,
120+
serde_json::to_string_pretty(&json).unwrap_or_default()
121+
);
122+
}
123+
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
124+
}
125+
}
126+
127+
/// Assert that the root endpoint (`/`) returns the expected API listing structure.
128+
pub async fn assert_api_root(monitoring_addr: SocketAddr) {
129+
let json = fetch_api_json(monitoring_addr, "/").await;
130+
assert_eq!(
131+
json["service"], "SRI Monitoring API",
132+
"Root endpoint should return service name, got: {}",
133+
json
134+
);
135+
assert!(
136+
json["endpoints"].is_object(),
137+
"Root endpoint should list endpoints, got: {}",
138+
json
139+
);
140+
}
141+
142+
/// Assert that `/api/v1/global` returns a valid response with the expected structure.
143+
/// Returns the parsed JSON for further assertions.
144+
pub async fn assert_api_global(monitoring_addr: SocketAddr) -> serde_json::Value {
145+
let json = fetch_api_json(monitoring_addr, "/api/v1/global").await;
146+
assert!(
147+
json["uptime_secs"].as_u64().is_some(),
148+
"Global endpoint should contain uptime_secs, got: {}",
149+
json
150+
);
151+
json
152+
}
153+
30154
/// Parse a specific metric value from Prometheus text format.
31155
/// Returns `None` if the metric line is not found.
32156
///

integration-tests/lib/utils.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,43 @@ pub fn into_static(m: AnyMessage<'_>) -> AnyMessage<'static> {
407407
}
408408

409409
pub mod http {
410+
/// Make a GET request that returns both the HTTP status code and the response body.
411+
/// Unlike `make_get_request`, this does NOT panic on non-2xx status codes (e.g. 404),
412+
/// making it suitable for testing API error responses.
413+
/// Only retries on 5xx errors or connection failures.
414+
pub fn make_get_request_with_status(url: &str, retries: usize) -> (i32, Vec<u8>) {
415+
for attempt in 1..=retries {
416+
let response = minreq::get(url).send();
417+
match response {
418+
Ok(res) => {
419+
let status_code = res.status_code;
420+
if (500..600).contains(&status_code) {
421+
eprintln!(
422+
"Attempt {attempt}: URL {url} returned a server error code {status_code}"
423+
);
424+
} else {
425+
return (status_code, res.as_bytes().to_vec());
426+
}
427+
}
428+
Err(err) => {
429+
eprintln!(
430+
"Attempt {}: Failed to fetch URL {}: {:?}",
431+
attempt + 1,
432+
url,
433+
err
434+
);
435+
}
436+
}
437+
438+
if attempt < retries {
439+
let delay = 1u64 << (attempt - 1);
440+
eprintln!("Retrying in {delay} seconds (exponential backoff)...");
441+
std::thread::sleep(std::time::Duration::from_secs(delay));
442+
}
443+
}
444+
panic!("Cannot reach URL {url} after {retries} attempts");
445+
}
446+
410447
pub fn make_get_request(download_url: &str, retries: usize) -> Vec<u8> {
411448
for attempt in 1..=retries {
412449
let response = minreq::get(download_url).send();

0 commit comments

Comments
 (0)