Skip to main content

stratum_apps/monitoring/
http_server.rs

1//! HTTP server for exposing monitoring data using Axum
2
3#[cfg(feature = "asic-rs-telemetry")]
4use super::miner_telemetry::{MinerTelemetry, MinerTelemetryStatus};
5use super::{
6    client::{
7        ExtendedChannelInfo, StandardChannelInfo, Sv2ClientInfo, Sv2ClientKind, Sv2ClientMetadata,
8        Sv2ClientsMonitoring, Sv2ClientsSummary,
9    },
10    prometheus_metrics::PrometheusMetrics,
11    routes,
12    server::{
13        ServerExtendedChannelInfo, ServerMonitoring, ServerStandardChannelInfo, ServerSummary,
14    },
15    snapshot_cache::SnapshotCache,
16    sv1::{Sv1ClientInfo, Sv1ClientsMonitoring, Sv1ClientsSummary},
17    GlobalInfo,
18};
19use axum::{
20    extract::{Path, Query, State},
21    http::StatusCode,
22    response::{IntoResponse, Json, Response},
23    routing::get,
24    Router,
25};
26use prometheus::{Encoder, TextEncoder};
27use serde::{Deserialize, Serialize};
28use std::{
29    future::Future,
30    net::SocketAddr,
31    sync::Arc,
32    time::{Duration, SystemTime, UNIX_EPOCH},
33};
34use tokio::net::TcpListener;
35use tracing::info;
36use utoipa::{IntoParams, OpenApi, ToSchema};
37use utoipa_swagger_ui::SwaggerUi;
38
39#[derive(OpenApi)]
40#[cfg_attr(
41    feature = "asic-rs-telemetry",
42    openapi(components(schemas(MinerTelemetry, MinerTelemetryStatus)))
43)]
44#[openapi(
45    info(
46        // This `info` block is the single source of truth for the API title
47        // and version. `handle_root` reads them back from `ApiDoc::openapi()`
48        // at runtime instead of duplicating the literals.
49        title = "SRI Monitoring API",
50        version = "0.1.0",
51        description = "HTTP JSON API for monitoring SV2 applications"
52    ),
53    paths(
54        handle_health,
55        handle_global,
56        handle_server,
57        handle_server_channels,
58        handle_clients,
59        handle_client_by_id,
60        handle_client_channels,
61        handle_sv1_clients,
62        handle_sv1_client_by_id,
63    ),
64    components(schemas(
65        GlobalInfo,
66        ServerSummary,
67        Sv2ClientsSummary,
68        ServerExtendedChannelInfo,
69        ServerStandardChannelInfo,
70        Sv2ClientKind,
71        Sv2ClientInfo,
72        Sv2ClientMetadata,
73        ExtendedChannelInfo,
74        StandardChannelInfo,
75        Sv1ClientInfo,
76        Sv1ClientsSummary,
77        HealthResponse,
78        ErrorResponse,
79        ServerResponse,
80        ServerChannelsResponse,
81        Sv2ClientsResponse,
82        Sv2ClientResponse,
83        Sv2ClientChannelsResponse,
84        Sv1ClientsResponse,
85    )),
86    tags(
87        (name = "health", description = "Health check endpoints"),
88        (name = "global", description = "Global statistics"),
89        (name = "server", description = "Server (upstream) monitoring"),
90        (name = "clients", description = "Clients (downstream) monitoring"),
91        (name = "sv1", description = "Sv1 clients monitoring (Translator Proxy only)")
92    )
93)]
94pub struct ApiDoc;
95
96/// Shared state for all HTTP handlers
97#[derive(Clone)]
98struct ServerState {
99    cache: Arc<SnapshotCache>,
100    start_time: u64,
101    metrics: PrometheusMetrics,
102}
103
104const DEFAULT_LIMIT: usize = 25;
105const MAX_LIMIT: usize = 100;
106
107#[derive(Deserialize, IntoParams)]
108struct Pagination {
109    /// Offset for pagination (default: 0)
110    #[serde(default)]
111    offset: usize,
112    /// Limit for pagination (default: 25, max: 100)
113    #[serde(default)]
114    limit: Option<usize>,
115}
116
117impl Pagination {
118    fn effective_limit(&self) -> usize {
119        self.limit
120            .map(|l| l.min(MAX_LIMIT))
121            .unwrap_or(DEFAULT_LIMIT)
122    }
123}
124
125fn paginate<T: Clone>(items: &[T], params: &Pagination) -> (usize, Vec<T>) {
126    let total = items.len();
127    let limit = params.effective_limit();
128    let offset = params.offset.min(total);
129    let sliced = items
130        .iter()
131        .skip(offset)
132        .take(limit)
133        .cloned()
134        .collect::<Vec<_>>();
135    (total, sliced)
136}
137
138/// HTTP server that exposes monitoring data as JSON
139pub struct MonitoringServer {
140    bind_address: SocketAddr,
141    state: ServerState,
142    refresh_interval: Duration,
143}
144
145impl MonitoringServer {
146    /// Create a new monitoring server with automatic cache refresh.
147    ///
148    /// This constructor creates a snapshot cache that decouples monitoring API
149    /// requests from business logic locks, eliminating the DoS vulnerability where
150    /// rapid API requests could cause lock contention with share validation and
151    /// job distribution.
152    ///
153    /// The cache is automatically refreshed in the background at the specified interval.
154    ///
155    /// # Arguments
156    ///
157    /// * `bind_address` - Address to bind the HTTP server to
158    /// * `server_monitoring` - Optional server (upstream) monitoring trait object
159    /// * `sv2_clients_monitoring` - Optional Sv2 clients (downstream) monitoring trait object
160    /// * `refresh_interval` - How often to refresh the cache (e.g., Duration::from_secs(15))
161    pub fn new(
162        bind_address: SocketAddr,
163        server_monitoring: Option<Arc<dyn ServerMonitoring + Send + Sync + 'static>>,
164        sv2_clients_monitoring: Option<Arc<dyn Sv2ClientsMonitoring + Send + Sync + 'static>>,
165        refresh_interval: Duration,
166    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
167        let start_time = SystemTime::now()
168            .duration_since(UNIX_EPOCH)
169            .unwrap_or_default()
170            .as_secs();
171
172        let has_server = server_monitoring.is_some();
173        let has_sv2_clients = sv2_clients_monitoring.is_some();
174
175        let metrics = PrometheusMetrics::new(has_server, has_sv2_clients, false)?;
176
177        // Create the snapshot cache with metrics attached so refresh()
178        // updates Prometheus gauges atomically alongside the snapshot data.
179        let cache = Arc::new(
180            SnapshotCache::new(refresh_interval, server_monitoring, sv2_clients_monitoring)
181                .with_metrics(metrics.clone()),
182        );
183
184        // Do initial refresh (populates both snapshot and Prometheus gauges)
185        cache.refresh();
186
187        Ok(Self {
188            bind_address,
189            refresh_interval,
190            state: ServerState {
191                cache,
192                start_time,
193                metrics,
194            },
195        })
196    }
197
198    /// Add Sv1 clients monitoring (optional, for Translator Proxy only)
199    ///
200    /// This must be called before `run()` if you want SV1 monitoring.
201    pub fn with_sv1_monitoring(
202        mut self,
203        sv1_monitoring: Arc<dyn Sv1ClientsMonitoring + Send + Sync + 'static>,
204    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
205        // Determine what sources the cache already has
206        let snapshot = self.state.cache.get_snapshot();
207        let has_server = snapshot.server_info.is_some();
208        let has_sv2_clients = snapshot.sv2_clients_summary.is_some();
209
210        // Create metrics with SV1 monitoring enabled
211        let metrics = PrometheusMetrics::new(has_server, has_sv2_clients, true)?;
212
213        // Add Sv1 clients source and attach new metrics to the cache
214        let cache = Arc::new(
215            Arc::try_unwrap(self.state.cache)
216                .unwrap_or_else(|arc| (*arc).clone())
217                .with_sv1_clients_source(sv1_monitoring)
218                .with_metrics(metrics.clone()),
219        );
220
221        // Refresh cache with new SV1 data (also updates Prometheus gauges)
222        cache.refresh();
223
224        self.state.metrics = metrics;
225        self.state.cache = cache;
226
227        Ok(self)
228    }
229
230    /// Run the monitoring server until the shutdown signal completes
231    ///
232    /// Starts an HTTP server that exposes monitoring data as JSON.
233    /// Also starts a background task that refreshes the snapshot cache periodically.
234    /// Both tasks shut down gracefully when `shutdown_signal` completes.
235    ///
236    /// Automatically exposes:
237    /// - Swagger UI at `/swagger-ui`
238    /// - OpenAPI spec at `/api-docs/openapi.json`
239    /// - Prometheus metrics at `/metrics`
240    pub async fn run(
241        self,
242        shutdown_signal: impl Future<Output = ()> + Send + 'static,
243    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
244        info!("Starting monitoring server on http://{}", self.bind_address);
245        info!("Cache refresh interval: {:?}", self.refresh_interval);
246
247        let listener = TcpListener::bind(self.bind_address).await?;
248
249        // Spawn background task to refresh cache periodically
250        let cache_for_refresh = self.state.cache.clone();
251        let refresh_interval = self.refresh_interval;
252        let refresh_handle = tokio::spawn(async move {
253            let mut interval = tokio::time::interval(refresh_interval);
254            loop {
255                interval.tick().await;
256                cache_for_refresh.refresh();
257            }
258        });
259
260        // Versioned JSON API under /api/v1
261        let api_v1 = Router::new()
262            .route(routes::segments::HEALTH, get(handle_health))
263            .route(routes::segments::GLOBAL, get(handle_global))
264            .route(routes::segments::SERVER, get(handle_server))
265            .route(
266                routes::segments::SERVER_CHANNELS,
267                get(handle_server_channels),
268            )
269            .route(routes::segments::CLIENTS, get(handle_clients))
270            .route(routes::segments::CLIENT_BY_ID, get(handle_client_by_id))
271            .route(
272                routes::segments::CLIENT_CHANNELS,
273                get(handle_client_channels),
274            )
275            .route(routes::segments::SV1_CLIENTS, get(handle_sv1_clients))
276            .route(
277                routes::segments::SV1_CLIENT_BY_ID,
278                get(handle_sv1_client_by_id),
279            );
280
281        let app = Router::new()
282            .route(routes::ROOT, get(handle_root))
283            .merge(SwaggerUi::new(routes::SWAGGER_UI).url(routes::OPENAPI_SPEC, ApiDoc::openapi()))
284            .nest(routes::API_V1_PREFIX, api_v1)
285            .route(routes::METRICS, get(handle_prometheus_metrics))
286            .with_state(self.state);
287
288        info!(
289            "Swagger UI available at http://{}/swagger-ui",
290            self.bind_address
291        );
292        info!(
293            "Prometheus metrics available at http://{}/metrics",
294            self.bind_address
295        );
296
297        let server_handle = axum::serve(listener, app).with_graceful_shutdown(async move {
298            shutdown_signal.await;
299            info!("Monitoring server received shutdown signal, stopping...");
300        });
301
302        // Run server and wait for shutdown
303        let result = server_handle.await;
304
305        // Stop the refresh task
306        refresh_handle.abort();
307
308        info!("Monitoring server stopped");
309        result.map_err(|e| e.into())
310    }
311}
312
313// Response types — used for both actual responses, OpenAPI documentation,
314// and as the canonical types for JSON deserialization in tests (both
315// in-crate unit tests and downstream integration tests).
316#[derive(Serialize, Deserialize, Debug, ToSchema)]
317pub struct HealthResponse {
318    pub status: String,
319    pub timestamp: u64,
320}
321
322#[derive(Serialize, Deserialize, Debug, ToSchema)]
323pub struct ErrorResponse {
324    pub error: String,
325}
326
327#[derive(Serialize, Deserialize, Debug, ToSchema)]
328pub struct ServerResponse {
329    pub extended_channels_count: usize,
330    pub standard_channels_count: usize,
331    pub total_hashrate: f32,
332}
333
334#[derive(Serialize, Deserialize, Debug, ToSchema)]
335pub struct ServerChannelsResponse {
336    pub offset: usize,
337    pub limit: usize,
338    pub total_extended: usize,
339    pub total_standard: usize,
340    pub extended_channels: Vec<ServerExtendedChannelInfo>,
341    pub standard_channels: Vec<ServerStandardChannelInfo>,
342}
343
344#[derive(Serialize, Deserialize, Debug, ToSchema)]
345pub struct Sv2ClientsResponse {
346    pub offset: usize,
347    pub limit: usize,
348    pub total: usize,
349    pub items: Vec<Sv2ClientMetadata>,
350}
351
352#[derive(Serialize, Deserialize, Debug, ToSchema)]
353pub struct Sv2ClientResponse {
354    pub client_id: usize,
355    pub client_kind: Sv2ClientKind,
356    pub extended_channels_count: usize,
357    pub standard_channels_count: usize,
358    pub total_hashrate: f32,
359    #[cfg(feature = "asic-rs-telemetry")]
360    #[schema(value_type = Option<String>)]
361    pub management_ip: Option<std::net::IpAddr>,
362    #[cfg(feature = "asic-rs-telemetry")]
363    pub miner_telemetry: Option<MinerTelemetry>,
364    #[cfg(feature = "asic-rs-telemetry")]
365    pub miner_telemetry_status: Option<MinerTelemetryStatus>,
366}
367
368#[derive(Serialize, Deserialize, Debug, ToSchema)]
369pub struct Sv2ClientChannelsResponse {
370    pub client_id: usize,
371    pub offset: usize,
372    pub limit: usize,
373    pub total_extended: usize,
374    pub total_standard: usize,
375    pub extended_channels: Vec<ExtendedChannelInfo>,
376    pub standard_channels: Vec<StandardChannelInfo>,
377}
378
379#[derive(Serialize, Deserialize, Debug, ToSchema)]
380pub struct Sv1ClientsResponse {
381    pub offset: usize,
382    pub limit: usize,
383    pub total: usize,
384    pub items: Vec<Sv1ClientInfo>,
385}
386
387/// Response shape for the root `/` endpoint: a service banner plus a map of
388/// available endpoints to their human-readable descriptions.
389#[derive(Serialize, Deserialize, Debug)]
390pub struct RootResponse {
391    pub service: String,
392    pub version: String,
393    pub endpoints: std::collections::BTreeMap<String, String>,
394}
395
396/// Root endpoint - lists all available APIs
397async fn handle_root() -> Json<RootResponse> {
398    let mut endpoints = std::collections::BTreeMap::new();
399    endpoints.insert(
400        routes::ROOT.to_string(),
401        "This endpoint - API listing".to_string(),
402    );
403    endpoints.insert(
404        routes::SWAGGER_UI.to_string(),
405        "Swagger UI (interactive API documentation)".to_string(),
406    );
407    endpoints.insert(
408        routes::OPENAPI_SPEC.to_string(),
409        "OpenAPI specification".to_string(),
410    );
411    endpoints.insert(routes::HEALTH.to_string(), "Health check".to_string());
412    endpoints.insert(routes::GLOBAL.to_string(), "Global statistics".to_string());
413    endpoints.insert(routes::SERVER.to_string(), "Server metadata".to_string());
414    endpoints.insert(
415        routes::SERVER_CHANNELS.to_string(),
416        "Server channels (paginated)".to_string(),
417    );
418    endpoints.insert(
419        routes::CLIENTS.to_string(),
420        "All Sv2 clients metadata (paginated)".to_string(),
421    );
422    endpoints.insert(
423        routes::CLIENT_BY_ID_PATTERN.to_string(),
424        "Single Sv2 client metadata".to_string(),
425    );
426    endpoints.insert(
427        routes::CLIENT_CHANNELS_PATTERN.to_string(),
428        "Sv2 client channels (paginated)".to_string(),
429    );
430    endpoints.insert(
431        routes::SV1_CLIENTS.to_string(),
432        "Sv1 clients (Translator Proxy only, paginated)".to_string(),
433    );
434    endpoints.insert(
435        routes::SV1_CLIENT_BY_ID_PATTERN.to_string(),
436        "Single Sv1 client (Translator Proxy only)".to_string(),
437    );
438    endpoints.insert(
439        routes::METRICS.to_string(),
440        "Prometheus metrics".to_string(),
441    );
442
443    // Pull title/version from the OpenAPI spec so the `/` listing, the
444    // OpenAPI document, and Swagger UI always agree.
445    let info = ApiDoc::openapi().info;
446    Json(RootResponse {
447        service: info.title,
448        version: info.version,
449        endpoints,
450    })
451}
452
453// Note: the `path = "..."` arguments to `#[utoipa::path(...)]` below must be
454// string literals — utoipa parses them at macro-expansion time and does not
455// accept `const` references. They must be kept in sync with `routes::*`.
456
457/// Health check endpoint
458#[utoipa::path(
459    get,
460    path = "/api/v1/health",
461    tag = "health",
462    responses(
463        (status = 200, description = "Service is healthy", body = HealthResponse)
464    )
465)]
466async fn handle_health() -> Json<HealthResponse> {
467    Json(HealthResponse {
468        status: "ok".to_string(),
469        timestamp: SystemTime::now()
470            .duration_since(UNIX_EPOCH)
471            .unwrap_or_default()
472            .as_secs(),
473    })
474}
475
476/// Get global statistics
477///
478/// Returns aggregated statistics for the server (upstream) and clients (downstream).
479/// Fields are omitted from the response if that type of monitoring is not enabled.
480///
481/// **Typical responses:**
482/// - **Pool/JDC**: `server` + `clients` (Sv2 downstream)
483/// - **tProxy**: `server` + `sv1_clients` (Sv1 miners)
484#[utoipa::path(
485    get,
486    path = "/api/v1/global",
487    tag = "global",
488    responses(
489        (status = 200, description = "Global statistics", body = GlobalInfo)
490    )
491)]
492async fn handle_global(State(state): State<ServerState>) -> Json<GlobalInfo> {
493    let uptime_secs = SystemTime::now()
494        .duration_since(UNIX_EPOCH)
495        .unwrap_or_default()
496        .as_secs()
497        - state.start_time;
498
499    let snapshot = state.cache.get_snapshot();
500
501    Json(GlobalInfo {
502        server: snapshot.server_summary,
503        sv2_clients: snapshot.sv2_clients_summary,
504        sv1_clients: snapshot.sv1_clients_summary,
505        uptime_secs,
506    })
507}
508
509/// Get server (upstream) metadata - use /server/channels for channel details
510#[utoipa::path(
511    get,
512    path = "/api/v1/server",
513    tag = "server",
514    responses(
515        (status = 200, description = "Server metadata", body = ServerResponse),
516        (status = 404, description = "Server monitoring not available", body = ErrorResponse)
517    )
518)]
519async fn handle_server(State(state): State<ServerState>) -> Response {
520    let snapshot = state.cache.get_snapshot();
521
522    match snapshot.server_summary {
523        Some(summary) => Json(ServerResponse {
524            extended_channels_count: summary.extended_channels,
525            standard_channels_count: summary.standard_channels,
526            total_hashrate: summary.total_hashrate,
527        })
528        .into_response(),
529        None => (
530            StatusCode::NOT_FOUND,
531            Json(ErrorResponse {
532                error: "Server monitoring not available".to_string(),
533            }),
534        )
535            .into_response(),
536    }
537}
538
539/// Get server channels (paginated)
540#[utoipa::path(
541    get,
542    path = "/api/v1/server/channels",
543    tag = "server",
544    params(Pagination),
545    responses(
546        (status = 200, description = "Server channels (paginated)", body = ServerChannelsResponse),
547        (status = 404, description = "Server monitoring not available", body = ErrorResponse)
548    )
549)]
550async fn handle_server_channels(
551    Query(params): Query<Pagination>,
552    State(state): State<ServerState>,
553) -> Response {
554    let snapshot = state.cache.get_snapshot();
555
556    match snapshot.server_info {
557        Some(server) => {
558            let (total_extended, extended_channels) = paginate(&server.extended_channels, &params);
559            let (total_standard, standard_channels) = paginate(&server.standard_channels, &params);
560
561            Json(ServerChannelsResponse {
562                offset: params.offset,
563                limit: params.effective_limit(),
564                total_extended,
565                total_standard,
566                extended_channels,
567                standard_channels,
568            })
569            .into_response()
570        }
571        None => (
572            StatusCode::NOT_FOUND,
573            Json(ErrorResponse {
574                error: "Server monitoring not available".to_string(),
575            }),
576        )
577            .into_response(),
578    }
579}
580
581/// Get all Sv2 clients (downstream) - returns metadata only, use /clients/{id}/channels for
582/// channels
583#[utoipa::path(
584    get,
585    path = "/api/v1/clients",
586    tag = "clients",
587    params(Pagination),
588    responses(
589        (status = 200, description = "List of Sv2 clients (metadata only)", body = Sv2ClientsResponse),
590        (status = 404, description = "Sv2 clients monitoring not available", body = ErrorResponse)
591    )
592)]
593async fn handle_clients(
594    Query(params): Query<Pagination>,
595    State(state): State<ServerState>,
596) -> Response {
597    let snapshot = state.cache.get_snapshot();
598
599    match snapshot.sv2_clients {
600        Some(ref sv2_clients) => {
601            let metadata: Vec<Sv2ClientMetadata> =
602                sv2_clients.iter().map(|c| c.to_metadata()).collect();
603            let (total, items) = paginate(&metadata, &params);
604
605            Json(Sv2ClientsResponse {
606                offset: params.offset,
607                limit: params.effective_limit(),
608                total,
609                items,
610            })
611            .into_response()
612        }
613        None => (
614            StatusCode::NOT_FOUND,
615            Json(ErrorResponse {
616                error: "Sv2 clients monitoring not available".to_string(),
617            }),
618        )
619            .into_response(),
620    }
621}
622
623/// Get a single Sv2 client by ID - returns metadata only, use /clients/{id}/channels for channels
624#[utoipa::path(
625    get,
626    path = "/api/v1/clients/{client_id}",
627    tag = "clients",
628    params(
629        ("client_id" = usize, Path, description = "Sv2 Client ID")
630    ),
631    responses(
632        (status = 200, description = "Sv2 client metadata", body = Sv2ClientResponse),
633        (status = 404, description = "Sv2 client not found", body = ErrorResponse)
634    )
635)]
636async fn handle_client_by_id(
637    Path(client_id): Path<usize>,
638    State(state): State<ServerState>,
639) -> Response {
640    let snapshot = state.cache.get_snapshot();
641
642    let sv2_clients = match snapshot.sv2_clients {
643        Some(ref clients) => clients,
644        None => {
645            return (
646                StatusCode::NOT_FOUND,
647                Json(ErrorResponse {
648                    error: "Sv2 clients monitoring not available".to_string(),
649                }),
650            )
651                .into_response();
652        }
653    };
654
655    match sv2_clients.iter().find(|c| c.client_id == client_id) {
656        Some(client) => Json(Sv2ClientResponse {
657            client_id,
658            client_kind: client.client_kind,
659            extended_channels_count: client.extended_channels.len(),
660            standard_channels_count: client.standard_channels.len(),
661            total_hashrate: client.total_hashrate(),
662            #[cfg(feature = "asic-rs-telemetry")]
663            management_ip: client.management_ip,
664            #[cfg(feature = "asic-rs-telemetry")]
665            miner_telemetry: client.miner_telemetry.clone(),
666            #[cfg(feature = "asic-rs-telemetry")]
667            miner_telemetry_status: client.miner_telemetry_status,
668        })
669        .into_response(),
670        None => (
671            StatusCode::NOT_FOUND,
672            Json(ErrorResponse {
673                error: format!("Sv2 client {client_id} not found"),
674            }),
675        )
676            .into_response(),
677    }
678}
679
680/// Get channels for a specific Sv2 client (paginated)
681#[utoipa::path(
682    get,
683    path = "/api/v1/clients/{client_id}/channels",
684    tag = "clients",
685    params(
686        ("client_id" = usize, Path, description = "Sv2 Client ID"),
687        Pagination
688    ),
689    responses(
690        (status = 200, description = "Sv2 client channels (paginated)", body = Sv2ClientChannelsResponse),
691        (status = 404, description = "Sv2 client not found", body = ErrorResponse)
692    )
693)]
694async fn handle_client_channels(
695    Path(client_id): Path<usize>,
696    Query(params): Query<Pagination>,
697    State(state): State<ServerState>,
698) -> Response {
699    let snapshot = state.cache.get_snapshot();
700
701    let sv2_clients = match snapshot.sv2_clients {
702        Some(ref clients) => clients,
703        None => {
704            return (
705                StatusCode::NOT_FOUND,
706                Json(ErrorResponse {
707                    error: "Sv2 clients monitoring not available".to_string(),
708                }),
709            )
710                .into_response();
711        }
712    };
713
714    match sv2_clients.iter().find(|c| c.client_id == client_id) {
715        Some(client) => {
716            let (total_extended, extended_channels) = paginate(&client.extended_channels, &params);
717            let (total_standard, standard_channels) = paginate(&client.standard_channels, &params);
718
719            Json(Sv2ClientChannelsResponse {
720                client_id,
721                offset: params.offset,
722                limit: params.effective_limit(),
723                total_extended,
724                total_standard,
725                extended_channels,
726                standard_channels,
727            })
728            .into_response()
729        }
730        None => (
731            StatusCode::NOT_FOUND,
732            Json(ErrorResponse {
733                error: format!("Sv2 client {client_id} not found"),
734            }),
735        )
736            .into_response(),
737    }
738}
739
740/// Get Sv1 clients (Translator Proxy only)
741#[utoipa::path(
742    get,
743    path = "/api/v1/sv1/clients",
744    tag = "sv1",
745    params(Pagination),
746    responses(
747        (status = 200, description = "List of Sv1 clients", body = Sv1ClientsResponse),
748        (status = 404, description = "Sv1 monitoring not available", body = ErrorResponse)
749    )
750)]
751async fn handle_sv1_clients(
752    Query(params): Query<Pagination>,
753    State(state): State<ServerState>,
754) -> Response {
755    let snapshot = state.cache.get_snapshot();
756
757    match snapshot.sv1_clients {
758        Some(ref sv1_clients) => {
759            let (total, items) = paginate(sv1_clients, &params);
760
761            Json(Sv1ClientsResponse {
762                offset: params.offset,
763                limit: params.effective_limit(),
764                total,
765                items,
766            })
767            .into_response()
768        }
769        None => (
770            StatusCode::NOT_FOUND,
771            Json(ErrorResponse {
772                error: "Sv1 client monitoring not available".to_string(),
773            }),
774        )
775            .into_response(),
776    }
777}
778
779/// Get a single Sv1 client by ID
780#[utoipa::path(
781    get,
782    path = "/api/v1/sv1/clients/{client_id}",
783    tag = "sv1",
784    params(
785        ("client_id" = usize, Path, description = "Sv1 client ID")
786    ),
787    responses(
788        (status = 200, description = "Sv1 client details", body = Sv1ClientInfo),
789        (status = 404, description = "Sv1 client not found", body = ErrorResponse)
790    )
791)]
792async fn handle_sv1_client_by_id(
793    Path(client_id): Path<usize>,
794    State(state): State<ServerState>,
795) -> Response {
796    let snapshot = state.cache.get_snapshot();
797
798    let sv1_clients = match snapshot.sv1_clients {
799        Some(ref clients) => clients,
800        None => {
801            return (
802                StatusCode::NOT_FOUND,
803                Json(ErrorResponse {
804                    error: "Sv1 client monitoring not available".to_string(),
805                }),
806            )
807                .into_response();
808        }
809    };
810
811    match sv1_clients.iter().find(|c| c.client_id == client_id) {
812        Some(client) => Json(client.clone()).into_response(),
813        None => (
814            StatusCode::NOT_FOUND,
815            Json(ErrorResponse {
816                error: format!("Sv1 client {client_id} not found"),
817            }),
818        )
819            .into_response(),
820    }
821}
822
823/// Handler for Prometheus metrics endpoint.
824///
825/// All GaugeVec metric values are updated atomically by the background cache refresh
826/// task in `SnapshotCache::refresh()`. This handler only needs to:
827/// 1. Set the uptime gauge (requires wall-clock time at scrape time)
828/// 2. Gather and encode all registered metrics
829///
830/// Because metric values are always kept in sync with the snapshot data, there is
831/// never a gap where label series momentarily disappear. Tests can assert on metrics
832/// directly after a cache refresh without polling for transient states.
833async fn handle_prometheus_metrics(State(state): State<ServerState>) -> Response {
834    // Uptime is the only metric set at scrape time (needs current wall clock)
835    let uptime_secs = SystemTime::now()
836        .duration_since(UNIX_EPOCH)
837        .unwrap_or_default()
838        .as_secs()
839        - state.start_time;
840    state.metrics.sv2_uptime_seconds.set(uptime_secs as f64);
841
842    // Gather and encode — all other metrics were set by the last cache refresh
843    let encoder = TextEncoder::new();
844    let metric_families = state.metrics.registry.gather();
845    let mut buffer = Vec::new();
846
847    match encoder.encode(&metric_families, &mut buffer) {
848        Ok(_) => match String::from_utf8(buffer) {
849            Ok(metrics_text) => (StatusCode::OK, metrics_text).into_response(),
850            Err(e) => (
851                StatusCode::INTERNAL_SERVER_ERROR,
852                Json(ErrorResponse {
853                    error: format!("UTF-8 error: {e}"),
854                }),
855            )
856                .into_response(),
857        },
858        Err(e) => (
859            StatusCode::INTERNAL_SERVER_ERROR,
860            Json(ErrorResponse {
861                error: format!("Encoding error: {e}"),
862            }),
863        )
864            .into_response(),
865    }
866}
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871    use crate::monitoring::server::ServerInfo;
872    use axum::body::Body;
873    use http_body_util::BodyExt;
874    use std::{collections::HashMap, sync::Mutex};
875    use stratum_core::mining_sv2::ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE;
876    use tower::ServiceExt;
877
878    // ── helpers ──────────────────────────────────────────────────────
879
880    fn create_extended_channel_info(channel_id: u32, hashrate: f32) -> ExtendedChannelInfo {
881        ExtendedChannelInfo {
882            channel_id,
883            user_identity: format!("user-ext-{}", channel_id),
884            nominal_hashrate: hashrate,
885            stable_hashrate: false,
886            target_hex: "00ff".into(),
887            requested_max_target_hex: "00ff".into(),
888            extranonce_prefix_hex: "aa".into(),
889            full_extranonce_size: 16,
890            rollable_extranonce_size: 4,
891            expected_shares_per_minute: 1.0,
892            shares_accepted: 10,
893            shares_rejected: 0,
894            shares_rejected_by_reason: HashMap::new(),
895            share_work_sum: 100.0,
896            last_share_sequence_number: 5,
897            best_diff: 50.0,
898            last_batch_accepted: 3,
899            last_batch_work_sum: 30,
900            share_batch_size: 10,
901            blocks_found: 0,
902        }
903    }
904
905    fn create_standard_channel_info(channel_id: u32, hashrate: f32) -> StandardChannelInfo {
906        StandardChannelInfo {
907            channel_id,
908            user_identity: format!("user-std-{}", channel_id),
909            nominal_hashrate: hashrate,
910            stable_hashrate: false,
911            target_hex: "00ff".into(),
912            requested_max_target_hex: "00ff".into(),
913            extranonce_prefix_hex: "bb".into(),
914            expected_shares_per_minute: 2.0,
915            shares_accepted: 20,
916            shares_rejected: 1,
917            shares_rejected_by_reason: HashMap::from([(
918                ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE.to_string(),
919                1,
920            )]),
921            share_work_sum: 200.0,
922            last_share_sequence_number: 8,
923            best_diff: 80.0,
924            last_batch_accepted: 5,
925            last_batch_work_sum: 50,
926            share_batch_size: 20,
927            blocks_found: 0,
928        }
929    }
930
931    fn create_server_extended_channel_info(
932        channel_id: u32,
933        hashrate: Option<f32>,
934    ) -> ServerExtendedChannelInfo {
935        ServerExtendedChannelInfo {
936            channel_id,
937            user_identity: format!("pool-ext-{}", channel_id),
938            nominal_hashrate: hashrate,
939            target_hex: "00ff".into(),
940            extranonce_prefix_hex: "aa".into(),
941            full_extranonce_size: 16,
942            rollable_extranonce_size: 4,
943            version_rolling: true,
944            shares_acknowledged: 10,
945            shares_rejected: 0,
946            shares_rejected_by_reason: HashMap::new(),
947            acknowledged_work_sum: 100,
948            validated_work_sum: 100.0,
949            shares_submitted: 12,
950            best_diff: 50.0,
951            blocks_found: 0,
952        }
953    }
954
955    fn create_server_standard_channel_info(
956        channel_id: u32,
957        hashrate: Option<f32>,
958    ) -> ServerStandardChannelInfo {
959        ServerStandardChannelInfo {
960            channel_id,
961            user_identity: format!("pool-std-{}", channel_id),
962            nominal_hashrate: hashrate,
963            target_hex: "00ff".into(),
964            extranonce_prefix_hex: "bb".into(),
965            shares_acknowledged: 20,
966            shares_submitted: 22,
967            shares_rejected: 1,
968            shares_rejected_by_reason: HashMap::from([(
969                ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE.to_string(),
970                1,
971            )]),
972            acknowledged_work_sum: 200,
973            validated_work_sum: 200.0,
974            best_diff: 80.0,
975            blocks_found: 0,
976        }
977    }
978
979    #[cfg(feature = "asic-rs-telemetry")]
980    fn create_miner_telemetry() -> MinerTelemetry {
981        MinerTelemetry {
982            make: Some("Acme".into()),
983            model: Some("HashBox".into()),
984            firmware_version: Some("1.2.3".into()),
985            reported_hashrate_hs: Some(100_000_000_000_000.0),
986            power_consumption_w: Some(3200.0),
987            efficiency_j_per_th: Some(32.0),
988            average_temperature_c: Some(68.0),
989            uptime_secs: Some(3600),
990            is_mining: Some(true),
991        }
992    }
993
994    fn create_sv1_client_info(id: usize, hashrate: Option<f32>) -> Sv1ClientInfo {
995        Sv1ClientInfo {
996            client_id: id,
997            channel_id: Some(id as u32),
998            connection_ip: format!("192.0.2.{}", id)
999                .parse()
1000                .expect("test IP address must be valid"),
1001            #[cfg(feature = "asic-rs-telemetry")]
1002            management_ip: None,
1003            sv1_username: format!("account.worker-{}", id),
1004            sv1_worker_name: format!("worker-{}", id),
1005            target_hex: "00ff".into(),
1006            hashrate,
1007            #[cfg(feature = "asic-rs-telemetry")]
1008            miner_telemetry: None,
1009            #[cfg(feature = "asic-rs-telemetry")]
1010            miner_telemetry_status: None,
1011            stable_hashrate: false,
1012            extranonce1_hex: "aabb".into(),
1013            extranonce2_len: 8,
1014            version_rolling_mask: Some("ffffffff".into()),
1015            version_rolling_min_bit: Some("00000000".into()),
1016        }
1017    }
1018
1019    struct MockServer(ServerInfo);
1020    impl ServerMonitoring for MockServer {
1021        fn get_server(&self) -> ServerInfo {
1022            self.0.clone()
1023        }
1024    }
1025
1026    struct MockClients(Vec<Sv2ClientInfo>);
1027    impl Sv2ClientsMonitoring for MockClients {
1028        fn get_sv2_clients(&self) -> Vec<Sv2ClientInfo> {
1029            self.0.clone()
1030        }
1031    }
1032
1033    struct MockSv1Clients(Vec<Sv1ClientInfo>);
1034    impl Sv1ClientsMonitoring for MockSv1Clients {
1035        fn get_sv1_clients(&self) -> Vec<Sv1ClientInfo> {
1036            self.0.clone()
1037        }
1038    }
1039
1040    /// Build a full Router with mock data for integration testing.
1041    fn build_test_app(
1042        server: Option<Arc<dyn ServerMonitoring + Send + Sync>>,
1043        clients: Option<Arc<dyn Sv2ClientsMonitoring + Send + Sync>>,
1044        sv1: Option<Arc<dyn Sv1ClientsMonitoring + Send + Sync>>,
1045    ) -> Router {
1046        let has_server = server.is_some();
1047        let has_clients = clients.is_some();
1048        let has_sv1 = sv1.is_some();
1049
1050        let metrics = PrometheusMetrics::new(has_server, has_clients, has_sv1).unwrap();
1051
1052        let cache = Arc::new(
1053            SnapshotCache::new(Duration::from_secs(60), server, clients)
1054                .with_metrics(metrics.clone()),
1055        );
1056
1057        let cache = if let Some(sv1_source) = sv1 {
1058            Arc::new(
1059                Arc::try_unwrap(cache)
1060                    .unwrap_or_else(|arc| (*arc).clone())
1061                    .with_sv1_clients_source(sv1_source),
1062            )
1063        } else {
1064            cache
1065        };
1066
1067        cache.refresh();
1068
1069        let start_time = SystemTime::now()
1070            .duration_since(UNIX_EPOCH)
1071            .unwrap_or_default()
1072            .as_secs();
1073
1074        let state = ServerState {
1075            cache,
1076            start_time,
1077            metrics,
1078        };
1079
1080        let api_v1 = Router::new()
1081            .route(routes::segments::HEALTH, get(handle_health))
1082            .route(routes::segments::GLOBAL, get(handle_global))
1083            .route(routes::segments::SERVER, get(handle_server))
1084            .route(
1085                routes::segments::SERVER_CHANNELS,
1086                get(handle_server_channels),
1087            )
1088            .route(routes::segments::CLIENTS, get(handle_clients))
1089            .route(routes::segments::CLIENT_BY_ID, get(handle_client_by_id))
1090            .route(
1091                routes::segments::CLIENT_CHANNELS,
1092                get(handle_client_channels),
1093            )
1094            .route(routes::segments::SV1_CLIENTS, get(handle_sv1_clients))
1095            .route(
1096                routes::segments::SV1_CLIENT_BY_ID,
1097                get(handle_sv1_client_by_id),
1098            );
1099
1100        Router::new()
1101            .route(routes::ROOT, get(handle_root))
1102            .nest(routes::API_V1_PREFIX, api_v1)
1103            .route(routes::METRICS, get(handle_prometheus_metrics))
1104            .with_state(state)
1105    }
1106
1107    async fn get_body(response: axum::response::Response) -> String {
1108        let body = response.into_body();
1109        let bytes = body.collect().await.unwrap().to_bytes();
1110        String::from_utf8(bytes.to_vec()).unwrap()
1111    }
1112
1113    fn make_request(uri: &str) -> axum::http::Request<Body> {
1114        axum::http::Request::builder()
1115            .uri(uri)
1116            .body(Body::empty())
1117            .unwrap()
1118    }
1119
1120    // ── Pagination unit tests ───────────────────────────────────────
1121
1122    #[test]
1123    fn pagination_effective_limit_default() {
1124        let p = Pagination {
1125            offset: 0,
1126            limit: None,
1127        };
1128        assert_eq!(p.effective_limit(), DEFAULT_LIMIT);
1129    }
1130
1131    #[test]
1132    fn pagination_effective_limit_capped_at_max() {
1133        let p = Pagination {
1134            offset: 0,
1135            limit: Some(500),
1136        };
1137        assert_eq!(p.effective_limit(), MAX_LIMIT);
1138    }
1139
1140    #[test]
1141    fn pagination_effective_limit_respects_small_value() {
1142        let p = Pagination {
1143            offset: 0,
1144            limit: Some(5),
1145        };
1146        assert_eq!(p.effective_limit(), 5);
1147    }
1148
1149    #[test]
1150    fn paginate_empty_slice() {
1151        let items: Vec<i32> = vec![];
1152        let params = Pagination {
1153            offset: 0,
1154            limit: Some(10),
1155        };
1156        let (total, result) = paginate(&items, &params);
1157        assert_eq!(total, 0);
1158        assert!(result.is_empty());
1159    }
1160
1161    #[test]
1162    fn paginate_basic() {
1163        let items: Vec<i32> = (0..50).collect();
1164        let params = Pagination {
1165            offset: 10,
1166            limit: Some(5),
1167        };
1168        let (total, result) = paginate(&items, &params);
1169        assert_eq!(total, 50);
1170        assert_eq!(result, vec![10, 11, 12, 13, 14]);
1171    }
1172
1173    #[test]
1174    fn paginate_offset_beyond_length() {
1175        let items: Vec<i32> = vec![1, 2, 3];
1176        let params = Pagination {
1177            offset: 100,
1178            limit: Some(10),
1179        };
1180        let (total, result) = paginate(&items, &params);
1181        assert_eq!(total, 3);
1182        assert!(result.is_empty());
1183    }
1184
1185    #[test]
1186    fn paginate_limit_exceeds_remaining() {
1187        let items: Vec<i32> = vec![1, 2, 3, 4, 5];
1188        let params = Pagination {
1189            offset: 3,
1190            limit: Some(10),
1191        };
1192        let (total, result) = paginate(&items, &params);
1193        assert_eq!(total, 5);
1194        assert_eq!(result, vec![4, 5]);
1195    }
1196
1197    // ── HTTP endpoint integration tests ─────────────────────────────
1198
1199    #[tokio::test]
1200    async fn health_endpoint_returns_ok() {
1201        let app = build_test_app(None, None, None);
1202        let response = app.oneshot(make_request(routes::HEALTH)).await.unwrap();
1203        assert_eq!(response.status(), StatusCode::OK);
1204
1205        let body = get_body(response).await;
1206        let resp: HealthResponse = serde_json::from_str(&body).unwrap();
1207        assert_eq!(resp.status, "ok");
1208    }
1209
1210    #[tokio::test]
1211    async fn root_endpoint_lists_endpoints() {
1212        let app = build_test_app(None, None, None);
1213        let response = app.oneshot(make_request(routes::ROOT)).await.unwrap();
1214        assert_eq!(response.status(), StatusCode::OK);
1215
1216        let body = get_body(response).await;
1217        let resp: RootResponse = serde_json::from_str(&body).unwrap();
1218        assert_eq!(resp.service, ApiDoc::openapi().info.title);
1219        assert!(resp.endpoints.contains_key(routes::HEALTH));
1220    }
1221
1222    #[tokio::test]
1223    async fn run_returns_error_when_bind_address_is_in_use() {
1224        let occupied_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1225        let occupied_addr = occupied_listener.local_addr().unwrap();
1226        let server =
1227            MonitoringServer::new(occupied_addr, None, None, Duration::from_millis(10)).unwrap();
1228
1229        let err = server.run(std::future::pending()).await.unwrap_err();
1230
1231        assert_eq!(
1232            err.downcast_ref::<std::io::Error>()
1233                .map(std::io::Error::kind),
1234            Some(std::io::ErrorKind::AddrInUse)
1235        );
1236    }
1237
1238    #[tokio::test]
1239    async fn global_endpoint_with_no_sources() {
1240        let app = build_test_app(None, None, None);
1241        let response = app.oneshot(make_request(routes::GLOBAL)).await.unwrap();
1242        assert_eq!(response.status(), StatusCode::OK);
1243
1244        let body = get_body(response).await;
1245        let resp: GlobalInfo = serde_json::from_str(&body).unwrap();
1246        assert!(resp.server.is_none());
1247        assert!(resp.sv2_clients.is_none());
1248    }
1249
1250    #[tokio::test]
1251    async fn global_endpoint_with_data() {
1252        let server = Arc::new(MockServer(ServerInfo {
1253            extended_channels: vec![create_server_extended_channel_info(1, Some(100.0))],
1254            standard_channels: vec![],
1255        }));
1256        let clients = Arc::new(MockClients(vec![Sv2ClientInfo {
1257            client_id: 1,
1258            client_kind: Sv2ClientKind::Miner,
1259            extended_channels: vec![create_extended_channel_info(1, 50.0)],
1260            standard_channels: vec![],
1261            #[cfg(feature = "asic-rs-telemetry")]
1262            management_ip: None,
1263            #[cfg(feature = "asic-rs-telemetry")]
1264            miner_telemetry: None,
1265            #[cfg(feature = "asic-rs-telemetry")]
1266            miner_telemetry_status: None,
1267        }]));
1268
1269        let app = build_test_app(
1270            Some(server as Arc<dyn ServerMonitoring + Send + Sync>),
1271            Some(clients as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
1272            None,
1273        );
1274        let response = app.oneshot(make_request(routes::GLOBAL)).await.unwrap();
1275        assert_eq!(response.status(), StatusCode::OK);
1276
1277        let body = get_body(response).await;
1278        let resp: GlobalInfo = serde_json::from_str(&body).unwrap();
1279        assert_eq!(resp.server.as_ref().unwrap().extended_channels, 1);
1280        assert_eq!(resp.sv2_clients.as_ref().unwrap().total_clients, 1);
1281    }
1282
1283    #[tokio::test]
1284    async fn server_endpoint_not_available() {
1285        let app = build_test_app(None, None, None);
1286        let response = app.oneshot(make_request(routes::SERVER)).await.unwrap();
1287        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1288    }
1289
1290    #[tokio::test]
1291    async fn server_endpoint_with_data() {
1292        let server = Arc::new(MockServer(ServerInfo {
1293            extended_channels: vec![create_server_extended_channel_info(1, Some(100.0))],
1294            standard_channels: vec![create_server_standard_channel_info(2, Some(50.0))],
1295        }));
1296
1297        let app = build_test_app(
1298            Some(server as Arc<dyn ServerMonitoring + Send + Sync>),
1299            None,
1300            None,
1301        );
1302        let response = app.oneshot(make_request(routes::SERVER)).await.unwrap();
1303        assert_eq!(response.status(), StatusCode::OK);
1304
1305        let body = get_body(response).await;
1306        let resp: ServerResponse = serde_json::from_str(&body).unwrap();
1307        assert_eq!(resp.extended_channels_count, 1);
1308        assert_eq!(resp.standard_channels_count, 1);
1309    }
1310
1311    #[tokio::test]
1312    async fn server_channels_endpoint_with_pagination() {
1313        let server = Arc::new(MockServer(ServerInfo {
1314            extended_channels: vec![
1315                create_server_extended_channel_info(1, Some(100.0)),
1316                create_server_extended_channel_info(2, Some(200.0)),
1317                create_server_extended_channel_info(3, Some(300.0)),
1318            ],
1319            standard_channels: vec![],
1320        }));
1321
1322        let app = build_test_app(
1323            Some(server as Arc<dyn ServerMonitoring + Send + Sync>),
1324            None,
1325            None,
1326        );
1327        let response = app
1328            .oneshot(make_request(&format!(
1329                "{}?offset=1&limit=1",
1330                routes::SERVER_CHANNELS
1331            )))
1332            .await
1333            .unwrap();
1334        assert_eq!(response.status(), StatusCode::OK);
1335
1336        let body = get_body(response).await;
1337        let resp: ServerChannelsResponse = serde_json::from_str(&body).unwrap();
1338        assert_eq!(resp.total_extended, 3);
1339        assert_eq!(resp.offset, 1);
1340        assert_eq!(resp.limit, 1);
1341        assert_eq!(resp.extended_channels.len(), 1);
1342    }
1343
1344    #[tokio::test]
1345    async fn server_channels_endpoint_keeps_rejected_shares_total_compatible() {
1346        let server = Arc::new(MockServer(ServerInfo {
1347            extended_channels: vec![],
1348            standard_channels: vec![create_server_standard_channel_info(1, Some(50.0))],
1349        }));
1350
1351        let app = build_test_app(
1352            Some(server as Arc<dyn ServerMonitoring + Send + Sync>),
1353            None,
1354            None,
1355        );
1356        let response = app
1357            .oneshot(make_request(routes::SERVER_CHANNELS))
1358            .await
1359            .unwrap();
1360        assert_eq!(response.status(), StatusCode::OK);
1361
1362        let body = get_body(response).await;
1363        let json: serde_json::Value = serde_json::from_str(&body).unwrap();
1364        let channel = &json["standard_channels"][0];
1365
1366        assert_eq!(channel["shares_rejected"], 1);
1367        assert_eq!(
1368            channel["shares_rejected_by_reason"][ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE],
1369            1
1370        );
1371    }
1372
1373    #[tokio::test]
1374    async fn clients_endpoint_not_available() {
1375        let app = build_test_app(None, None, None);
1376        let response = app.oneshot(make_request(routes::CLIENTS)).await.unwrap();
1377        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1378    }
1379
1380    #[tokio::test]
1381    async fn clients_endpoint_returns_metadata() {
1382        let clients = Arc::new(MockClients(vec![
1383            Sv2ClientInfo {
1384                client_id: 1,
1385                client_kind: Sv2ClientKind::Miner,
1386                extended_channels: vec![create_extended_channel_info(1, 100.0)],
1387                standard_channels: vec![],
1388                #[cfg(feature = "asic-rs-telemetry")]
1389                management_ip: None,
1390                #[cfg(feature = "asic-rs-telemetry")]
1391                miner_telemetry: Some(create_miner_telemetry()),
1392                #[cfg(feature = "asic-rs-telemetry")]
1393                miner_telemetry_status: Some(MinerTelemetryStatus::Matched),
1394            },
1395            Sv2ClientInfo {
1396                client_id: 2,
1397                client_kind: Sv2ClientKind::Miner,
1398                extended_channels: vec![],
1399                standard_channels: vec![create_standard_channel_info(1, 50.0)],
1400                #[cfg(feature = "asic-rs-telemetry")]
1401                management_ip: None,
1402                #[cfg(feature = "asic-rs-telemetry")]
1403                miner_telemetry: None,
1404                #[cfg(feature = "asic-rs-telemetry")]
1405                miner_telemetry_status: None,
1406            },
1407        ]));
1408
1409        let app = build_test_app(
1410            None,
1411            Some(clients as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
1412            None,
1413        );
1414        let response = app.oneshot(make_request(routes::CLIENTS)).await.unwrap();
1415        assert_eq!(response.status(), StatusCode::OK);
1416
1417        let body = get_body(response).await;
1418        let resp: Sv2ClientsResponse = serde_json::from_str(&body).unwrap();
1419        assert_eq!(resp.total, 2);
1420        assert_eq!(resp.items.len(), 2);
1421        assert_eq!(resp.items[0].client_id, 1);
1422        assert_eq!(resp.items[0].client_kind, Sv2ClientKind::Miner);
1423        #[cfg(feature = "asic-rs-telemetry")]
1424        assert_eq!(
1425            resp.items[0]
1426                .miner_telemetry
1427                .as_ref()
1428                .and_then(|telemetry| telemetry.model.as_deref()),
1429            Some("HashBox")
1430        );
1431    }
1432
1433    #[tokio::test]
1434    async fn client_by_id_found() {
1435        let clients = Arc::new(MockClients(vec![Sv2ClientInfo {
1436            client_id: 42,
1437            client_kind: Sv2ClientKind::Miner,
1438            extended_channels: vec![create_extended_channel_info(1, 100.0)],
1439            standard_channels: vec![create_standard_channel_info(2, 50.0)],
1440            #[cfg(feature = "asic-rs-telemetry")]
1441            management_ip: None,
1442            #[cfg(feature = "asic-rs-telemetry")]
1443            miner_telemetry: Some(create_miner_telemetry()),
1444            #[cfg(feature = "asic-rs-telemetry")]
1445            miner_telemetry_status: Some(MinerTelemetryStatus::Matched),
1446        }]));
1447
1448        let app = build_test_app(
1449            None,
1450            Some(clients as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
1451            None,
1452        );
1453        let response = app
1454            .oneshot(make_request(&routes::client_by_id(42)))
1455            .await
1456            .unwrap();
1457        assert_eq!(response.status(), StatusCode::OK);
1458
1459        let body = get_body(response).await;
1460        let resp: Sv2ClientResponse = serde_json::from_str(&body).unwrap();
1461        assert_eq!(resp.client_id, 42);
1462        assert_eq!(resp.client_kind, Sv2ClientKind::Miner);
1463        assert_eq!(resp.extended_channels_count, 1);
1464        assert_eq!(resp.standard_channels_count, 1);
1465        #[cfg(feature = "asic-rs-telemetry")]
1466        assert_eq!(
1467            resp.miner_telemetry
1468                .as_ref()
1469                .and_then(|telemetry| telemetry.model.as_deref()),
1470            Some("HashBox")
1471        );
1472    }
1473
1474    #[tokio::test]
1475    async fn client_by_id_not_found() {
1476        let clients = Arc::new(MockClients(vec![Sv2ClientInfo {
1477            client_id: 1,
1478            client_kind: Sv2ClientKind::Miner,
1479            extended_channels: vec![],
1480            standard_channels: vec![],
1481            #[cfg(feature = "asic-rs-telemetry")]
1482            management_ip: None,
1483            #[cfg(feature = "asic-rs-telemetry")]
1484            miner_telemetry: None,
1485            #[cfg(feature = "asic-rs-telemetry")]
1486            miner_telemetry_status: None,
1487        }]));
1488
1489        let app = build_test_app(
1490            None,
1491            Some(clients as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
1492            None,
1493        );
1494        let response = app
1495            .oneshot(make_request(&routes::client_by_id(999)))
1496            .await
1497            .unwrap();
1498        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1499    }
1500
1501    #[tokio::test]
1502    async fn client_channels_with_pagination() {
1503        let clients = Arc::new(MockClients(vec![Sv2ClientInfo {
1504            client_id: 1,
1505            client_kind: Sv2ClientKind::Miner,
1506            extended_channels: vec![
1507                create_extended_channel_info(10, 100.0),
1508                create_extended_channel_info(11, 200.0),
1509                create_extended_channel_info(12, 300.0),
1510            ],
1511            standard_channels: vec![create_standard_channel_info(20, 50.0)],
1512            #[cfg(feature = "asic-rs-telemetry")]
1513            management_ip: None,
1514            #[cfg(feature = "asic-rs-telemetry")]
1515            miner_telemetry: None,
1516            #[cfg(feature = "asic-rs-telemetry")]
1517            miner_telemetry_status: None,
1518        }]));
1519
1520        let app = build_test_app(
1521            None,
1522            Some(clients as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
1523            None,
1524        );
1525        let response = app
1526            .oneshot(make_request(&format!(
1527                "{}?offset=1&limit=2",
1528                routes::client_channels(1)
1529            )))
1530            .await
1531            .unwrap();
1532        assert_eq!(response.status(), StatusCode::OK);
1533
1534        let body = get_body(response).await;
1535        let resp: Sv2ClientChannelsResponse = serde_json::from_str(&body).unwrap();
1536        assert_eq!(resp.client_id, 1);
1537        assert_eq!(resp.total_extended, 3);
1538        assert_eq!(resp.total_standard, 1);
1539        assert_eq!(resp.extended_channels.len(), 2);
1540    }
1541
1542    #[tokio::test]
1543    async fn sv1_clients_not_available() {
1544        let app = build_test_app(None, None, None);
1545        let response = app
1546            .oneshot(make_request(routes::SV1_CLIENTS))
1547            .await
1548            .unwrap();
1549        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1550    }
1551
1552    #[tokio::test]
1553    async fn sv1_clients_with_data() {
1554        let sv1 = Arc::new(MockSv1Clients(vec![
1555            create_sv1_client_info(1, Some(100.0)),
1556            create_sv1_client_info(2, Some(200.0)),
1557        ]));
1558
1559        let app = build_test_app(
1560            None,
1561            None,
1562            Some(sv1 as Arc<dyn Sv1ClientsMonitoring + Send + Sync>),
1563        );
1564        let response = app
1565            .oneshot(make_request(routes::SV1_CLIENTS))
1566            .await
1567            .unwrap();
1568        assert_eq!(response.status(), StatusCode::OK);
1569
1570        let body = get_body(response).await;
1571        let resp: Sv1ClientsResponse = serde_json::from_str(&body).unwrap();
1572        assert_eq!(resp.total, 2);
1573        assert_eq!(resp.items.len(), 2);
1574    }
1575
1576    #[tokio::test]
1577    async fn sv1_client_by_id_found() {
1578        let sv1 = Arc::new(MockSv1Clients(vec![create_sv1_client_info(7, Some(500.0))]));
1579
1580        let app = build_test_app(
1581            None,
1582            None,
1583            Some(sv1 as Arc<dyn Sv1ClientsMonitoring + Send + Sync>),
1584        );
1585        let response = app
1586            .oneshot(make_request(&routes::sv1_client_by_id(7)))
1587            .await
1588            .unwrap();
1589        assert_eq!(response.status(), StatusCode::OK);
1590
1591        let body = get_body(response).await;
1592        let resp: Sv1ClientInfo = serde_json::from_str(&body).unwrap();
1593        assert_eq!(resp.client_id, 7);
1594    }
1595
1596    #[tokio::test]
1597    async fn sv1_client_by_id_not_found() {
1598        let sv1 = Arc::new(MockSv1Clients(vec![create_sv1_client_info(1, Some(100.0))]));
1599
1600        let app = build_test_app(
1601            None,
1602            None,
1603            Some(sv1 as Arc<dyn Sv1ClientsMonitoring + Send + Sync>),
1604        );
1605        let response = app
1606            .oneshot(make_request(&routes::sv1_client_by_id(999)))
1607            .await
1608            .unwrap();
1609        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1610    }
1611
1612    #[tokio::test]
1613    async fn metrics_endpoint_returns_prometheus_format() {
1614        let server = Arc::new(MockServer(ServerInfo {
1615            extended_channels: vec![create_server_extended_channel_info(1, Some(100.0))],
1616            standard_channels: vec![],
1617        }));
1618        let clients = Arc::new(MockClients(vec![Sv2ClientInfo {
1619            client_id: 1,
1620            client_kind: Sv2ClientKind::Miner,
1621            extended_channels: vec![create_extended_channel_info(1, 50.0)],
1622            standard_channels: vec![],
1623            #[cfg(feature = "asic-rs-telemetry")]
1624            management_ip: None,
1625            #[cfg(feature = "asic-rs-telemetry")]
1626            miner_telemetry: None,
1627            #[cfg(feature = "asic-rs-telemetry")]
1628            miner_telemetry_status: None,
1629        }]));
1630
1631        let app = build_test_app(
1632            Some(server as Arc<dyn ServerMonitoring + Send + Sync>),
1633            Some(clients as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
1634            None,
1635        );
1636        let response = app.oneshot(make_request(routes::METRICS)).await.unwrap();
1637        assert_eq!(response.status(), StatusCode::OK);
1638
1639        let body = get_body(response).await;
1640        assert!(body.contains("sv2_uptime_seconds"));
1641        assert!(body.contains("sv2_server_channels"));
1642        assert!(body.contains("sv2_clients_total"));
1643    }
1644
1645    #[tokio::test]
1646    async fn metrics_endpoint_with_no_sources() {
1647        let app = build_test_app(None, None, None);
1648        let response = app.oneshot(make_request(routes::METRICS)).await.unwrap();
1649        assert_eq!(response.status(), StatusCode::OK);
1650
1651        let body = get_body(response).await;
1652        // Uptime is always present
1653        assert!(body.contains("sv2_uptime_seconds"));
1654        // Server/client metrics should NOT be present when sources are None
1655        assert!(!body.contains("sv2_server_channels"));
1656        assert!(!body.contains("sv2_clients_total"));
1657    }
1658
1659    // Mutable mock that allows changing data between requests
1660    struct MutableMockClients(Mutex<Vec<Sv2ClientInfo>>);
1661    impl Sv2ClientsMonitoring for MutableMockClients {
1662        fn get_sv2_clients(&self) -> Vec<Sv2ClientInfo> {
1663            self.0.lock().unwrap().clone()
1664        }
1665    }
1666
1667    /// Verify that stale channel labels are removed without a reset gap.
1668    ///
1669    /// Scenario: First scrape has client with channel 1 and channel 2.
1670    /// Second scrape: channel 2 is gone. The test verifies that:
1671    /// - Channel 1 metrics are still present (no gap)
1672    /// - Channel 2 metrics are removed (stale cleanup)
1673    #[tokio::test]
1674    async fn metrics_stale_labels_removed_without_reset_gap() {
1675        let mut channel_2 = create_extended_channel_info(2, 200.0);
1676        channel_2.shares_rejected = 1;
1677        channel_2.shares_rejected_by_reason =
1678            HashMap::from([(ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE.to_string(), 1)]);
1679
1680        let initial_clients = vec![Sv2ClientInfo {
1681            client_id: 1,
1682            client_kind: Sv2ClientKind::Miner,
1683            extended_channels: vec![create_extended_channel_info(1, 100.0), channel_2],
1684            standard_channels: vec![],
1685            #[cfg(feature = "asic-rs-telemetry")]
1686            management_ip: None,
1687            #[cfg(feature = "asic-rs-telemetry")]
1688            miner_telemetry: None,
1689            #[cfg(feature = "asic-rs-telemetry")]
1690            miner_telemetry_status: None,
1691        }];
1692
1693        let mock_clients = Arc::new(MutableMockClients(Mutex::new(initial_clients)));
1694        let metrics = PrometheusMetrics::new(false, true, false).unwrap();
1695        let cache = Arc::new(
1696            SnapshotCache::new(
1697                Duration::from_secs(60),
1698                None,
1699                Some(mock_clients.clone() as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
1700            )
1701            .with_metrics(metrics.clone()),
1702        );
1703        cache.refresh();
1704
1705        let start_time = SystemTime::now()
1706            .duration_since(UNIX_EPOCH)
1707            .unwrap_or_default()
1708            .as_secs();
1709
1710        let state = ServerState {
1711            cache: cache.clone(),
1712            start_time,
1713            metrics,
1714        };
1715
1716        let app = Router::new()
1717            .route(routes::METRICS, get(handle_prometheus_metrics))
1718            .with_state(state);
1719
1720        // First scrape — both channels present
1721        let response = app
1722            .clone()
1723            .oneshot(make_request(routes::METRICS))
1724            .await
1725            .unwrap();
1726        let body = get_body(response).await;
1727        // Prometheus sorts label keys alphabetically: channel_id, client_id, user_identity
1728        assert!(
1729            body.contains("sv2_client_shares_accepted_total{channel_id=\"1\",client_id=\"1\""),
1730            "Channel 1 should be present on first scrape"
1731        );
1732        assert!(
1733            body.contains("sv2_client_shares_accepted_total{channel_id=\"2\",client_id=\"1\""),
1734            "Channel 2 should be present on first scrape"
1735        );
1736        assert!(
1737            body.contains("sv2_client_shares_rejected_total{channel_id=\"2\",client_id=\"1\",error_code=\"duplicate-share\""),
1738            "Channel 2 rejected-share metric should be present on first scrape"
1739        );
1740
1741        // Remove channel 2 from mock data and refresh cache
1742        {
1743            let mut clients = mock_clients.0.lock().unwrap();
1744            clients[0].extended_channels.retain(|c| c.channel_id == 1);
1745        }
1746        cache.refresh();
1747
1748        // Second scrape — channel 2 should be removed, channel 1 still present
1749        let response = app
1750            .clone()
1751            .oneshot(make_request(routes::METRICS))
1752            .await
1753            .unwrap();
1754        let body = get_body(response).await;
1755        assert!(
1756            body.contains("sv2_client_shares_accepted_total{channel_id=\"1\",client_id=\"1\""),
1757            "Channel 1 should still be present after stale removal"
1758        );
1759        assert!(
1760            !body.contains("sv2_client_shares_accepted_total{channel_id=\"2\",client_id=\"1\""),
1761            "Channel 2 should be removed as stale"
1762        );
1763        assert!(
1764            !body.contains("sv2_client_shares_rejected_total{channel_id=\"2\",client_id=\"1\",error_code=\"duplicate-share\""),
1765            "Channel 2 rejected-share metric should be removed as stale"
1766        );
1767    }
1768
1769    /// Regression test for lazy-loading of `sv2_*_shares_rejected_total`.
1770    ///
1771    /// A `GaugeVec` only emits a series after `with_label_values(...).set(...)` runs at
1772    /// least once. With the rejection metric populated only inside a loop over
1773    /// `channel.shares_rejected_by_reason`, a channel with zero rejections produces no
1774    /// series at all in `/metrics` — Grafana panels fail to load and alerting rules
1775    /// silently never fire.
1776    ///
1777    /// Pre-seeding the spec-defined error codes from `mining_sv2::ERROR_CODE_SUBMIT_SHARES_*`
1778    /// to `0` on every refresh fixes this. This test asserts the metric is emitted with
1779    /// zero rejections.
1780    #[tokio::test]
1781    async fn shares_rejected_metric_emitted_with_zero_rejections() {
1782        // Server channel with zero rejections.
1783        // Helper defaults for standard channel set shares_rejected=1; override to 0.
1784        let mut server_info = super::super::server::ServerInfo {
1785            extended_channels: vec![create_server_extended_channel_info(1, Some(100.0))],
1786            standard_channels: vec![create_server_standard_channel_info(2, Some(50.0))],
1787        };
1788        server_info.standard_channels[0].shares_rejected = 0;
1789        server_info.standard_channels[0].shares_rejected_by_reason = HashMap::new();
1790
1791        let server = Arc::new(MockServer(server_info));
1792
1793        // Client channel with zero rejections
1794        let clients = Arc::new(MockClients(vec![Sv2ClientInfo {
1795            client_id: 1,
1796            client_kind: Sv2ClientKind::Miner,
1797            extended_channels: vec![create_extended_channel_info(1, 100.0)],
1798            standard_channels: vec![],
1799            #[cfg(feature = "asic-rs-telemetry")]
1800            management_ip: None,
1801            #[cfg(feature = "asic-rs-telemetry")]
1802            miner_telemetry: None,
1803            #[cfg(feature = "asic-rs-telemetry")]
1804            miner_telemetry_status: None,
1805        }]));
1806
1807        let app = build_test_app(
1808            Some(server as Arc<dyn ServerMonitoring + Send + Sync>),
1809            Some(clients as Arc<dyn super::super::client::Sv2ClientsMonitoring + Send + Sync>),
1810            None,
1811        );
1812
1813        let response = app.oneshot(make_request(routes::METRICS)).await.unwrap();
1814        let body = get_body(response).await;
1815
1816        // Both metrics MUST appear with the spec-defined error_code labels pre-seeded to 0.
1817        assert!(
1818            body.contains("sv2_server_shares_rejected_total{channel_id=\"1\",error_code=\"stale-share\""),
1819            "sv2_server_shares_rejected_total stale-share label must be pre-seeded to 0; got:\n{body}"
1820        );
1821        assert!(
1822            body.contains("sv2_server_shares_rejected_total{channel_id=\"1\",error_code=\"duplicate-share\""),
1823            "sv2_server_shares_rejected_total duplicate-share label must be pre-seeded to 0; got:\n{body}"
1824        );
1825        assert!(
1826            body.contains("sv2_client_shares_rejected_total{channel_id=\"1\",client_id=\"1\",error_code=\"stale-share\""),
1827            "sv2_client_shares_rejected_total stale-share label must be pre-seeded to 0; got:\n{body}"
1828        );
1829    }
1830
1831    // ── Edge-case unit tests (pagination, missing data, invalid params) ──
1832
1833    #[test]
1834    fn paginate_with_limit_zero() {
1835        // effective_limit(Some(0)) = 0.min(MAX_LIMIT) = 0, so take(0) returns nothing
1836        let items: Vec<i32> = (0..50).collect();
1837        let params = Pagination {
1838            offset: 0,
1839            limit: Some(0),
1840        };
1841        let (total, result) = paginate(&items, &params);
1842        assert_eq!(total, 50);
1843        assert!(result.is_empty(), "limit=0 should return no items");
1844    }
1845
1846    #[tokio::test]
1847    async fn server_channels_not_available() {
1848        let app = build_test_app(None, None, None);
1849        let response = app
1850            .oneshot(make_request(routes::SERVER_CHANNELS))
1851            .await
1852            .unwrap();
1853        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1854
1855        let body = get_body(response).await;
1856        let resp: ErrorResponse = serde_json::from_str(&body).unwrap();
1857        assert!(!resp.error.is_empty());
1858    }
1859
1860    #[tokio::test]
1861    async fn client_by_id_no_monitoring() {
1862        // When client monitoring is not available at all, any client_id returns 404
1863        let app = build_test_app(None, None, None);
1864        let response = app
1865            .oneshot(make_request(&routes::client_by_id(1)))
1866            .await
1867            .unwrap();
1868        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1869
1870        let body = get_body(response).await;
1871        let resp: ErrorResponse = serde_json::from_str(&body).unwrap();
1872        assert!(!resp.error.is_empty());
1873    }
1874
1875    #[tokio::test]
1876    async fn client_channels_client_not_found() {
1877        // Client monitoring is available but the specific client_id does not exist
1878        let clients = Arc::new(MockClients(vec![Sv2ClientInfo {
1879            client_id: 1,
1880            client_kind: Sv2ClientKind::Miner,
1881            extended_channels: vec![],
1882            standard_channels: vec![],
1883            #[cfg(feature = "asic-rs-telemetry")]
1884            management_ip: None,
1885            #[cfg(feature = "asic-rs-telemetry")]
1886            miner_telemetry: None,
1887            #[cfg(feature = "asic-rs-telemetry")]
1888            miner_telemetry_status: None,
1889        }]));
1890
1891        let app = build_test_app(
1892            None,
1893            Some(clients as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
1894            None,
1895        );
1896        let response = app
1897            .oneshot(make_request(&routes::client_channels(999)))
1898            .await
1899            .unwrap();
1900        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1901
1902        let body = get_body(response).await;
1903        let resp: ErrorResponse = serde_json::from_str(&body).unwrap();
1904        assert!(resp.error.contains("999"));
1905    }
1906
1907    #[tokio::test]
1908    async fn client_channels_no_monitoring() {
1909        // When client monitoring is not available at all
1910        let app = build_test_app(None, None, None);
1911        let response = app
1912            .oneshot(make_request(&routes::client_channels(1)))
1913            .await
1914            .unwrap();
1915        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1916    }
1917
1918    #[tokio::test]
1919    async fn sv1_client_by_id_no_monitoring() {
1920        // When SV1 monitoring is not available at all
1921        let app = build_test_app(None, None, None);
1922        let response = app
1923            .oneshot(make_request(&routes::sv1_client_by_id(1)))
1924            .await
1925            .unwrap();
1926        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1927
1928        let body = get_body(response).await;
1929        let resp: ErrorResponse = serde_json::from_str(&body).unwrap();
1930        assert!(!resp.error.is_empty());
1931    }
1932
1933    #[tokio::test]
1934    async fn clients_pagination_offset_and_limit() {
1935        let clients = Arc::new(MockClients(vec![
1936            Sv2ClientInfo {
1937                client_id: 1,
1938                client_kind: Sv2ClientKind::Miner,
1939                extended_channels: vec![create_extended_channel_info(1, 100.0)],
1940                standard_channels: vec![],
1941                #[cfg(feature = "asic-rs-telemetry")]
1942                management_ip: None,
1943                #[cfg(feature = "asic-rs-telemetry")]
1944                miner_telemetry: None,
1945                #[cfg(feature = "asic-rs-telemetry")]
1946                miner_telemetry_status: None,
1947            },
1948            Sv2ClientInfo {
1949                client_id: 2,
1950                client_kind: Sv2ClientKind::Miner,
1951                extended_channels: vec![],
1952                standard_channels: vec![create_standard_channel_info(1, 50.0)],
1953                #[cfg(feature = "asic-rs-telemetry")]
1954                management_ip: None,
1955                #[cfg(feature = "asic-rs-telemetry")]
1956                miner_telemetry: None,
1957                #[cfg(feature = "asic-rs-telemetry")]
1958                miner_telemetry_status: None,
1959            },
1960            Sv2ClientInfo {
1961                client_id: 3,
1962                client_kind: Sv2ClientKind::Miner,
1963                extended_channels: vec![create_extended_channel_info(2, 200.0)],
1964                standard_channels: vec![],
1965                #[cfg(feature = "asic-rs-telemetry")]
1966                management_ip: None,
1967                #[cfg(feature = "asic-rs-telemetry")]
1968                miner_telemetry: None,
1969                #[cfg(feature = "asic-rs-telemetry")]
1970                miner_telemetry_status: None,
1971            },
1972        ]));
1973
1974        let app = build_test_app(
1975            None,
1976            Some(clients as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
1977            None,
1978        );
1979        let response = app
1980            .oneshot(make_request(&format!(
1981                "{}?offset=1&limit=1",
1982                routes::CLIENTS
1983            )))
1984            .await
1985            .unwrap();
1986        assert_eq!(response.status(), StatusCode::OK);
1987
1988        let body = get_body(response).await;
1989        let resp: Sv2ClientsResponse = serde_json::from_str(&body).unwrap();
1990        assert_eq!(resp.total, 3);
1991        assert_eq!(resp.offset, 1);
1992        assert_eq!(resp.limit, 1);
1993        assert_eq!(resp.items.len(), 1);
1994        assert_eq!(resp.items[0].client_id, 2);
1995    }
1996
1997    #[tokio::test]
1998    async fn sv1_clients_pagination() {
1999        let sv1 = Arc::new(MockSv1Clients(vec![
2000            create_sv1_client_info(1, Some(100.0)),
2001            create_sv1_client_info(2, Some(200.0)),
2002            create_sv1_client_info(3, Some(300.0)),
2003        ]));
2004
2005        let app = build_test_app(
2006            None,
2007            None,
2008            Some(sv1 as Arc<dyn Sv1ClientsMonitoring + Send + Sync>),
2009        );
2010        let response = app
2011            .oneshot(make_request(&format!(
2012                "{}?offset=2&limit=10",
2013                routes::SV1_CLIENTS
2014            )))
2015            .await
2016            .unwrap();
2017        assert_eq!(response.status(), StatusCode::OK);
2018
2019        let body = get_body(response).await;
2020        let resp: Sv1ClientsResponse = serde_json::from_str(&body).unwrap();
2021        assert_eq!(resp.total, 3);
2022        assert_eq!(resp.offset, 2);
2023        assert_eq!(resp.items.len(), 1);
2024        assert_eq!(resp.items[0].client_id, 3);
2025    }
2026
2027    #[tokio::test]
2028    async fn global_endpoint_with_sv1_data() {
2029        let sv1 = Arc::new(MockSv1Clients(vec![create_sv1_client_info(1, Some(100.0))]));
2030
2031        let app = build_test_app(
2032            None,
2033            None,
2034            Some(sv1 as Arc<dyn Sv1ClientsMonitoring + Send + Sync>),
2035        );
2036        let response = app.oneshot(make_request(routes::GLOBAL)).await.unwrap();
2037        assert_eq!(response.status(), StatusCode::OK);
2038
2039        let body = get_body(response).await;
2040        let resp: GlobalInfo = serde_json::from_str(&body).unwrap();
2041        // Server and SV2 clients should be None
2042        assert!(resp.server.is_none());
2043        assert!(resp.sv2_clients.is_none());
2044        // SV1 clients should be present
2045        assert_eq!(resp.sv1_clients.as_ref().unwrap().total_clients, 1);
2046    }
2047}