Skip to main content

bhtune_server/routes/
opc.rs

1//! OPC DA diagnostic routes: `GET /api/opc/servers`, `GET /api/opc/capabilities`,
2//! `GET /api/opc/browse`, `DELETE /api/opc/browse/sessions/:id`, `GET /api/opc/search`,
3//! `GET /api/opc/read`, and the indexed-search status/refresh/control routes -- back the GUI's
4//! server dropdown, typed tag-tree browser, namespace search, index management, and connection
5//! test.
6//!
7//! Read-only diagnostics remain independent of [`crate::state::AppState::active_run`], and every
8//! OPC operation is bounded by an explicit [`OPC_QUERY_TIMEOUT_SECS`] timeout (see that constant's
9//! doc comment for why one is needed at all). Index management does not acquire the active-tune
10//! lock either: starting or controlling a gateway inventory must not block, or be blocked by, an
11//! in-flight tune.
12
13use std::convert::Infallible;
14use std::future::Future;
15use std::time::Duration;
16
17use axum::extract::{Path, Query, State};
18use axum::response::sse::{Event, KeepAlive, Sse};
19use axum::routing::{delete, get, post};
20use axum::{Json, Router};
21use bhtune_cli::commands::tune::sample_quality_from_driver;
22use bhtune_db::models::SampleQuality;
23use bhtune_driver::{
24    BrowseNode, BrowseNodeKind, BrowsePage, BrowsePageRequest, BrowseSource, Driver,
25    DriverCapabilities, DriverResult, IndexedSearchMatch, IndexedSearchProgress,
26    NamespaceOrganization, OpcDaDriver, SearchEvent, SearchIndexControlAction, SearchIndexRequest,
27    SearchIndexResponse, SearchIndexStatus, SearchMatch, SearchMatchMode, SearchRequest,
28    list_opcda_servers,
29};
30use chrono::{DateTime, Utc};
31use serde::{Deserialize, Serialize};
32use utoipa::{IntoParams, ToSchema};
33
34use crate::error::{ApiError, ErrorBody};
35use crate::state::AppState;
36
37/// Bounds every OPC DA call this module makes. `opcda_bridge::Client::connect` has no
38/// connect timeout of its own (plain `tonic`, no `connect_timeout` configured), so a
39/// firewalled or black-holed gateway host would otherwise hang a request for however long
40/// the OS's own TCP-connect timeout is -- potentially minutes. 30s matches
41/// `bhtune-cli`'s `default_op_or_restore_timeout_secs()` (also 30) for consistency with the
42/// rest of the codebase, rather than inventing a new number. Each connect/browse/read call
43/// gets its own separate budget via [`with_timeout`], not one combined timeout spanning
44/// connect *and* the operation together.
45const OPC_QUERY_TIMEOUT_SECS: u64 = 30;
46
47/// Runs `fut` (one OPC DA driver call) under an [`OPC_QUERY_TIMEOUT_SECS`] deadline, mapping
48/// both a [`bhtune_driver::DriverError`] and an elapsed deadline to [`ApiError::BadRequest`]
49/// -- every failure this module can produce is "the gateway/tag couldn't be reached in
50/// time", a client-actionable diagnostic outcome, never an [`ApiError::Internal`] bug in this
51/// server. `what` names the attempted operation (e.g. `"connect to OPC server 'X'"`) so the
52/// error message identifies which step failed.
53async fn with_timeout<T>(
54    what: &str,
55    fut: impl Future<Output = DriverResult<T>>,
56) -> Result<T, ApiError> {
57    match tokio::time::timeout(Duration::from_secs(OPC_QUERY_TIMEOUT_SECS), fut).await {
58        Ok(Ok(value)) => Ok(value),
59        Ok(Err(err)) => Err(ApiError::BadRequest(format!("{what}: {err}"))),
60        Err(_) => Err(ApiError::BadRequest(format!(
61            "{what}: no response within {OPC_QUERY_TIMEOUT_SECS}s"
62        ))),
63    }
64}
65
66/// Query parameters for `GET /api/opc/servers`.
67#[derive(Debug, Deserialize, IntoParams)]
68#[into_params(parameter_in = Query)]
69pub struct OpcServersQuery {
70    /// Overrides the configured/default bridge host for this one request, matching `bhtune
71    /// opc servers --bridge-host`.
72    pub bridge_host: Option<String>,
73}
74
75/// Response body of `GET /api/opc/servers`.
76#[derive(Debug, Serialize, ToSchema)]
77pub struct OpcServersResponse {
78    pub servers: Vec<String>,
79}
80
81/// List every OPC DA server registered on the bridge gateway's own host.
82///
83/// `GET /api/opc/servers` -- powers the GUI's server dropdown. Server discovery needs only a
84/// bridge host, not a ProgID, so it cannot be a `Driver` trait method (constructing a
85/// `Driver` already requires the ProgID this call exists to find); it's the free function
86/// `bhtune_driver::list_opcda_servers` instead. An empty `servers` array is a normal, valid
87/// answer, not an error -- only a connection failure or timeout is a 400.
88#[utoipa::path(
89    get,
90    path = "/api/opc/servers",
91    tag = "opc",
92    params(OpcServersQuery),
93    responses(
94        (status = 200, body = OpcServersResponse),
95        (status = 400, description = "The bridge gateway could not be reached in time.", body = ErrorBody),
96    ),
97)]
98pub(crate) async fn servers(
99    State(state): State<AppState>,
100    Query(query): Query<OpcServersQuery>,
101) -> Result<Json<OpcServersResponse>, ApiError> {
102    let config = state.config_snapshot()?;
103    let bridge_host = bhtune_cli::config::resolve_bridge_host(query.bridge_host, &config);
104    let servers = with_timeout("list OPC DA servers", list_opcda_servers(&bridge_host)).await?;
105    Ok(Json(OpcServersResponse { servers }))
106}
107
108/// Query parameters for `GET /api/opc/capabilities`.
109#[derive(Debug, Deserialize, IntoParams)]
110#[into_params(parameter_in = Query)]
111pub struct OpcServerQuery {
112    pub bridge_host: Option<String>,
113    pub opc_server: Option<String>,
114}
115
116#[derive(Debug, Serialize, ToSchema)]
117pub struct OpcCapabilitiesResponse {
118    pub application_version: String,
119    pub protocol_version: String,
120    pub max_page_size: u32,
121    pub supports_browse_sessions: bool,
122    pub supports_search: bool,
123    pub organization: String,
124    pub source: String,
125    pub supports_indexed_search: bool,
126    pub indexed_search_protocol_version: String,
127    pub max_indexed_search_results: u32,
128    pub search_index_state: String,
129}
130
131impl From<DriverCapabilities> for OpcCapabilitiesResponse {
132    fn from(capabilities: DriverCapabilities) -> Self {
133        Self {
134            application_version: capabilities.application_version,
135            protocol_version: capabilities.protocol_version,
136            max_page_size: capabilities.max_page_size,
137            supports_browse_sessions: capabilities.supports_browse_sessions,
138            supports_search: capabilities.supports_search,
139            organization: organization_name(capabilities.organization).to_string(),
140            source: source_name(capabilities.source).to_string(),
141            supports_indexed_search: capabilities.supports_indexed_search,
142            indexed_search_protocol_version: capabilities.indexed_search_protocol_version,
143            max_indexed_search_results: capabilities.max_indexed_search_results,
144            search_index_state: capabilities.search_index_state.to_string(),
145        }
146    }
147}
148
149/// Report browse capabilities for one OPC DA server.
150#[utoipa::path(
151    get,
152    path = "/api/opc/capabilities",
153    tag = "opc",
154    params(OpcServerQuery),
155    responses(
156        (status = 200, body = OpcCapabilitiesResponse),
157        (status = 400, description = "The bridge or OPC server could not be reached.", body = ErrorBody),
158    ),
159)]
160pub(crate) async fn capabilities(
161    State(state): State<AppState>,
162    Query(query): Query<OpcServerQuery>,
163) -> Result<Json<OpcCapabilitiesResponse>, ApiError> {
164    let config = state.config_snapshot()?;
165    let bridge_host = bhtune_cli::config::resolve_bridge_host(query.bridge_host, &config);
166    let opc_server = bhtune_cli::config::resolve_server(query.opc_server, &config)
167        .map_err(|err| ApiError::BadRequest(err.to_string()))?;
168    let driver = with_timeout(
169        &format!("connect to OPC server '{opc_server}' via bridge '{bridge_host}'"),
170        OpcDaDriver::connect(&bridge_host, opc_server),
171    )
172    .await?;
173    let capabilities =
174        with_timeout("discover OPC browse capabilities", driver.capabilities()).await?;
175    Ok(Json(capabilities.into()))
176}
177
178/// Query parameters shared by indexed-search status and search-index actions.
179#[derive(Debug, Deserialize, IntoParams)]
180#[into_params(parameter_in = Query)]
181pub struct OpcSearchIndexServerQuery {
182    pub bridge_host: Option<String>,
183    pub opc_server: Option<String>,
184}
185
186#[derive(Debug, Serialize, ToSchema)]
187pub struct OpcIndexedSearchProgressResponse {
188    pub branches_visited: u64,
189    pub entries_seen: u64,
190    pub unique_items: u64,
191    pub active_time_ms: u64,
192    pub paused_time_ms: u64,
193    pub items_per_second: f64,
194    pub estimated_remaining_ms: Option<u64>,
195}
196
197impl From<IndexedSearchProgress> for OpcIndexedSearchProgressResponse {
198    fn from(progress: IndexedSearchProgress) -> Self {
199        Self {
200            branches_visited: progress.branches_visited,
201            entries_seen: progress.entries_seen,
202            unique_items: progress.unique_items,
203            active_time_ms: progress.active_time_ms,
204            paused_time_ms: progress.paused_time_ms,
205            items_per_second: progress.items_per_second,
206            estimated_remaining_ms: progress.estimated_remaining_ms,
207        }
208    }
209}
210
211#[derive(Debug, Serialize, ToSchema)]
212pub struct OpcSearchIndexStatusResponse {
213    pub server: String,
214    pub state: String,
215    pub auto_refresh_enabled: bool,
216    pub active_generation: u64,
217    pub entry_count: u64,
218    pub unique_item_count: u64,
219    pub started_at: Option<String>,
220    pub completed_at: Option<String>,
221    pub last_error: Option<String>,
222    pub database_bytes: u64,
223    pub organization: String,
224    pub source: String,
225    pub progress: Option<OpcIndexedSearchProgressResponse>,
226    pub scheduler: OpcIndexSchedulerResponse,
227}
228
229#[derive(Debug, Serialize, ToSchema)]
230pub struct OpcIndexSchedulerResponse {
231    pub next_refresh_at: Option<String>,
232    pub last_attempt_at: Option<String>,
233    pub last_success_at: Option<String>,
234    pub last_success_duration_ms: Option<u64>,
235    pub retry_after: Option<String>,
236    pub consecutive_failures: u32,
237    pub circuit_open: bool,
238}
239
240impl From<SearchIndexStatus> for OpcSearchIndexStatusResponse {
241    fn from(status: SearchIndexStatus) -> Self {
242        Self {
243            server: status.server,
244            state: status.state.to_string(),
245            auto_refresh_enabled: status.auto_refresh_enabled,
246            active_generation: status.active_generation,
247            entry_count: status.entry_count,
248            unique_item_count: status.unique_item_count,
249            started_at: status.started_at,
250            completed_at: status.completed_at,
251            last_error: status.last_error,
252            database_bytes: status.database_bytes,
253            organization: organization_name(status.organization).to_string(),
254            source: source_name(status.source).to_string(),
255            progress: status.progress.map(Into::into),
256            scheduler: OpcIndexSchedulerResponse {
257                next_refresh_at: status.scheduler.next_refresh_at,
258                last_attempt_at: status.scheduler.last_attempt_at,
259                last_success_at: status.scheduler.last_success_at,
260                last_success_duration_ms: status.scheduler.last_success_duration_ms,
261                retry_after: status.scheduler.retry_after,
262                consecutive_failures: status.scheduler.consecutive_failures,
263                circuit_open: status.scheduler.circuit_open,
264            },
265        }
266    }
267}
268
269#[derive(Debug, Serialize, ToSchema)]
270pub struct OpcIndexedSearchMatchResponse {
271    pub item_id: String,
272    pub display_name: String,
273    pub kind: OpcBrowseNodeKind,
274    pub breadcrumbs: Vec<String>,
275}
276
277impl From<IndexedSearchMatch> for OpcIndexedSearchMatchResponse {
278    fn from(found: IndexedSearchMatch) -> Self {
279        Self {
280            item_id: found.item_id,
281            display_name: found.display_name,
282            kind: found.kind.into(),
283            breadcrumbs: found.breadcrumbs,
284        }
285    }
286}
287
288#[derive(Debug, Serialize, ToSchema)]
289pub struct OpcSearchIndexResponse {
290    pub matches: Vec<OpcIndexedSearchMatchResponse>,
291    pub has_more: bool,
292    pub status: OpcSearchIndexStatusResponse,
293}
294
295impl From<SearchIndexResponse> for OpcSearchIndexResponse {
296    fn from(response: SearchIndexResponse) -> Self {
297        Self {
298            matches: response.matches.into_iter().map(Into::into).collect(),
299            has_more: response.has_more,
300            status: response.status.into(),
301        }
302    }
303}
304
305async fn connect_search_index_driver(
306    state: &AppState,
307    bridge_host: Option<String>,
308    opc_server: Option<String>,
309) -> Result<OpcDaDriver, ApiError> {
310    let config = state.config_snapshot()?;
311    let bridge_host = bhtune_cli::config::resolve_bridge_host(bridge_host, &config);
312    let opc_server = bhtune_cli::config::resolve_server(opc_server, &config)
313        .map_err(|err| ApiError::BadRequest(err.to_string()))?;
314    with_timeout(
315        &format!("connect to OPC server '{opc_server}' via bridge '{bridge_host}'"),
316        OpcDaDriver::connect(&bridge_host, opc_server),
317    )
318    .await
319}
320
321/// Return the persistent namespace-index status for one OPC DA server.
322#[utoipa::path(
323    get,
324    path = "/api/opc/search-index/status",
325    tag = "opc",
326    params(OpcSearchIndexServerQuery),
327    responses(
328        (status = 200, body = OpcSearchIndexStatusResponse),
329        (status = 400, description = "The bridge or OPC server could not be reached.", body = ErrorBody),
330    ),
331)]
332pub(crate) async fn search_index_status(
333    State(state): State<AppState>,
334    Query(query): Query<OpcSearchIndexServerQuery>,
335) -> Result<Json<OpcSearchIndexStatusResponse>, ApiError> {
336    let driver = connect_search_index_driver(&state, query.bridge_host, query.opc_server).await?;
337    let status = with_timeout("read OPC search-index status", driver.search_index_status()).await?;
338    Ok(Json(status.into()))
339}
340
341/// Query the gateway-owned persistent namespace index. This is a bounded unary request and
342/// never falls back to the legacy live traversal search.
343#[derive(Debug, Deserialize, IntoParams)]
344#[into_params(parameter_in = Query)]
345pub struct OpcSearchIndexQuery {
346    pub bridge_host: Option<String>,
347    pub opc_server: Option<String>,
348    pub query: String,
349    #[serde(default = "default_search_match_mode")]
350    pub match_mode: String,
351    #[serde(default = "default_index_search_max_results")]
352    #[param(minimum = 1)]
353    pub max_results: u32,
354}
355
356fn default_index_search_max_results() -> u32 {
357    bhtune_driver::DEFAULT_INDEX_SEARCH_MAX_RESULTS
358}
359
360#[utoipa::path(
361    get,
362    path = "/api/opc/search-index/search",
363    tag = "opc",
364    params(OpcSearchIndexQuery),
365    responses(
366        (status = 200, body = OpcSearchIndexResponse),
367        (status = 400, description = "The indexed-search request or gateway connection is invalid.", body = ErrorBody),
368    ),
369)]
370pub(crate) async fn search_index(
371    State(state): State<AppState>,
372    Query(query): Query<OpcSearchIndexQuery>,
373) -> Result<Json<OpcSearchIndexResponse>, ApiError> {
374    if query.query.trim().is_empty() {
375        return Err(ApiError::BadRequest(
376            "a search query is required".to_string(),
377        ));
378    }
379    let match_mode = parse_search_match_mode(&query.match_mode)?;
380    let max_results = validate_positive(query.max_results, "max_results")?;
381    let driver = connect_search_index_driver(&state, query.bridge_host, query.opc_server).await?;
382    let response = with_timeout(
383        "search the OPC namespace index",
384        driver.search_index(SearchIndexRequest::new(
385            query.query,
386            match_mode,
387            max_results,
388        )),
389    )
390    .await?;
391    Ok(Json(response.into()))
392}
393
394/// Query parameters for `POST /api/opc/search-index/refresh`.
395#[derive(Debug, Deserialize, IntoParams)]
396#[into_params(parameter_in = Query)]
397pub struct OpcSearchIndexRefreshQuery {
398    pub bridge_host: Option<String>,
399    pub opc_server: Option<String>,
400    pub force: Option<bool>,
401}
402
403#[utoipa::path(
404    post,
405    path = "/api/opc/search-index/refresh",
406    tag = "opc",
407    params(OpcSearchIndexRefreshQuery),
408    responses(
409        (status = 200, body = OpcSearchIndexStatusResponse),
410        (status = 400, description = "The refresh request or gateway connection is invalid.", body = ErrorBody),
411    ),
412)]
413pub(crate) async fn refresh_search_index(
414    State(state): State<AppState>,
415    Query(query): Query<OpcSearchIndexRefreshQuery>,
416) -> Result<Json<OpcSearchIndexStatusResponse>, ApiError> {
417    let driver = connect_search_index_driver(&state, query.bridge_host, query.opc_server).await?;
418    let status = with_timeout(
419        "refresh the OPC namespace index",
420        driver.refresh_search_index(query.force.unwrap_or(false)),
421    )
422    .await?;
423    Ok(Json(status.into()))
424}
425
426/// Query parameters for `POST /api/opc/search-index/auto-refresh`.
427#[derive(Debug, Deserialize, IntoParams)]
428#[into_params(parameter_in = Query)]
429pub struct OpcSearchIndexAutoRefreshQuery {
430    pub bridge_host: Option<String>,
431    pub opc_server: Option<String>,
432    pub enabled: bool,
433}
434
435#[utoipa::path(
436    post,
437    path = "/api/opc/search-index/auto-refresh",
438    tag = "opc",
439    params(OpcSearchIndexAutoRefreshQuery),
440    responses(
441        (status = 200, body = OpcSearchIndexStatusResponse),
442        (status = 400, description = "The auto-refresh request or gateway connection is invalid.", body = ErrorBody),
443    ),
444)]
445pub(crate) async fn set_search_index_auto_refresh(
446    State(state): State<AppState>,
447    Query(query): Query<OpcSearchIndexAutoRefreshQuery>,
448) -> Result<Json<OpcSearchIndexStatusResponse>, ApiError> {
449    let driver = connect_search_index_driver(&state, query.bridge_host, query.opc_server).await?;
450    let status = with_timeout(
451        "set OPC namespace index auto-refresh",
452        driver.set_search_index_auto_refresh(query.enabled),
453    )
454    .await?;
455    Ok(Json(status.into()))
456}
457
458#[utoipa::path(
459    delete,
460    path = "/api/opc/search-index",
461    tag = "opc",
462    params(OpcSearchIndexServerQuery),
463    responses(
464        (status = 200, body = OpcSearchIndexStatusResponse),
465        (status = 400, description = "The delete request or gateway connection is invalid.", body = ErrorBody),
466    ),
467)]
468pub(crate) async fn delete_search_index(
469    State(state): State<AppState>,
470    Query(query): Query<OpcSearchIndexServerQuery>,
471) -> Result<Json<OpcSearchIndexStatusResponse>, ApiError> {
472    let driver = connect_search_index_driver(&state, query.bridge_host, query.opc_server).await?;
473    let status = with_timeout(
474        "delete the OPC namespace index",
475        driver.delete_search_index(),
476    )
477    .await?;
478    Ok(Json(status.into()))
479}
480
481/// Query parameters for `POST /api/opc/search-index/control`.
482#[derive(Debug, Deserialize, IntoParams)]
483#[into_params(parameter_in = Query)]
484pub struct OpcSearchIndexControlQuery {
485    pub bridge_host: Option<String>,
486    pub opc_server: Option<String>,
487    pub action: String,
488}
489
490fn parse_search_index_control_action(value: &str) -> Result<SearchIndexControlAction, ApiError> {
491    match value {
492        "pause" => Ok(SearchIndexControlAction::Pause),
493        "resume" => Ok(SearchIndexControlAction::Resume),
494        "cancel" => Ok(SearchIndexControlAction::Cancel),
495        _ => Err(ApiError::BadRequest(
496            "action must be one of: pause, resume, cancel".to_string(),
497        )),
498    }
499}
500
501#[utoipa::path(
502    post,
503    path = "/api/opc/search-index/control",
504    tag = "opc",
505    params(OpcSearchIndexControlQuery),
506    responses(
507        (status = 200, body = OpcSearchIndexStatusResponse),
508        (status = 400, description = "The control action or gateway connection is invalid.", body = ErrorBody),
509    ),
510)]
511pub(crate) async fn control_search_index(
512    State(state): State<AppState>,
513    Query(query): Query<OpcSearchIndexControlQuery>,
514) -> Result<Json<OpcSearchIndexStatusResponse>, ApiError> {
515    let action = parse_search_index_control_action(&query.action)?;
516    let driver = connect_search_index_driver(&state, query.bridge_host, query.opc_server).await?;
517    let status = with_timeout(
518        "control the OPC namespace index",
519        driver.control_search_index(action),
520    )
521    .await?;
522    Ok(Json(status.into()))
523}
524
525/// Query parameters for `GET /api/opc/browse`.
526#[derive(Debug, Deserialize, IntoParams)]
527#[into_params(parameter_in = Query)]
528pub struct OpcBrowseQuery {
529    pub bridge_host: Option<String>,
530    pub opc_server: Option<String>,
531    pub session_id: Option<String>,
532    pub parent_node_key: Option<String>,
533    pub page_token: Option<String>,
534    #[serde(default = "default_page_size")]
535    pub page_size: u32,
536    pub refresh: Option<bool>,
537}
538
539fn default_page_size() -> u32 {
540    bhtune_driver::DEFAULT_PAGE_SIZE
541}
542
543fn validate_positive(value: u32, field: &str) -> Result<u32, ApiError> {
544    if value == 0 {
545        return Err(ApiError::BadRequest(format!(
546            "{field} must be greater than zero"
547        )));
548    }
549    Ok(value)
550}
551
552#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
553#[serde(rename_all = "snake_case")]
554pub enum OpcBrowseNodeKind {
555    Unspecified,
556    Branch,
557    Item,
558    BranchAndItem,
559}
560
561impl From<BrowseNodeKind> for OpcBrowseNodeKind {
562    fn from(kind: BrowseNodeKind) -> Self {
563        match kind {
564            BrowseNodeKind::Unspecified => Self::Unspecified,
565            BrowseNodeKind::Branch => Self::Branch,
566            BrowseNodeKind::Item => Self::Item,
567            BrowseNodeKind::BranchAndItem => Self::BranchAndItem,
568        }
569    }
570}
571
572/// One node returned by `GET /api/opc/browse`. `node_key` and `item_id` must remain separate:
573/// the former is an opaque navigation key, while the latter is the exact selectable OPC DA
574/// ItemID and may contain namespace punctuation with no relationship to hierarchy.
575#[derive(Debug, Serialize, ToSchema)]
576pub struct OpcBrowseNodeResponse {
577    pub node_key: String,
578    pub display_name: String,
579    pub kind: OpcBrowseNodeKind,
580    pub item_id: Option<String>,
581}
582
583impl From<BrowseNode> for OpcBrowseNodeResponse {
584    fn from(node: BrowseNode) -> Self {
585        Self {
586            node_key: node.node_key,
587            display_name: node.display_name,
588            kind: node.kind.into(),
589            item_id: node.item_id,
590        }
591    }
592}
593
594#[derive(Debug, Serialize, ToSchema)]
595pub struct OpcBrowseResponse {
596    pub session_id: String,
597    pub nodes: Vec<OpcBrowseNodeResponse>,
598    pub next_page_token: Option<String>,
599    pub complete: bool,
600    pub organization: String,
601    pub source: String,
602    pub warning: Option<String>,
603}
604
605impl From<BrowsePage> for OpcBrowseResponse {
606    fn from(page: BrowsePage) -> Self {
607        Self {
608            session_id: page.session_id,
609            nodes: page.nodes.into_iter().map(Into::into).collect(),
610            next_page_token: page.next_page_token,
611            complete: page.complete,
612            organization: organization_name(page.organization).to_string(),
613            source: source_name(page.source).to_string(),
614            warning: page.warning,
615        }
616    }
617}
618
619fn organization_name(value: NamespaceOrganization) -> &'static str {
620    match value {
621        NamespaceOrganization::Unspecified => "unspecified",
622        NamespaceOrganization::Flat => "flat",
623        NamespaceOrganization::Hierarchical => "hierarchical",
624    }
625}
626
627fn source_name(value: BrowseSource) -> &'static str {
628    match value {
629        BrowseSource::Unspecified => "unspecified",
630        BrowseSource::Da3 => "da3",
631        BrowseSource::Da2 => "da2",
632        BrowseSource::Flat => "flat",
633        BrowseSource::Derived => "derived",
634    }
635}
636
637/// List one bounded page of immediate children. A missing `session_id` opens a new session and
638/// lists its root; all later calls round-trip the returned opaque session/node/token values.
639#[utoipa::path(
640    get,
641    path = "/api/opc/browse",
642    tag = "opc",
643    params(OpcBrowseQuery),
644    responses(
645        (status = 200, body = OpcBrowseResponse),
646        (status = 400, description = "No OPC server was specified, the browse state is invalid, or the gateway could not be reached.", body = ErrorBody),
647    ),
648)]
649pub(crate) async fn browse(
650    State(state): State<AppState>,
651    Query(query): Query<OpcBrowseQuery>,
652) -> Result<Json<OpcBrowseResponse>, ApiError> {
653    let config = state.config_snapshot()?;
654    let bridge_host = bhtune_cli::config::resolve_bridge_host(query.bridge_host, &config);
655    let opc_server = bhtune_cli::config::resolve_server(query.opc_server, &config)
656        .map_err(|err| ApiError::BadRequest(err.to_string()))?;
657    let page_size = validate_positive(query.page_size, "page_size")?;
658    let driver = with_timeout(
659        &format!("connect to OPC server '{opc_server}' via bridge '{bridge_host}'"),
660        OpcDaDriver::connect(&bridge_host, opc_server.clone()),
661    )
662    .await?;
663    let request = BrowsePageRequest {
664        session_id: query.session_id,
665        parent_node_key: query.parent_node_key,
666        page_token: query.page_token,
667        page_size,
668        refresh: query.refresh.unwrap_or(false),
669    };
670    let page = with_timeout("browse OPC DA namespace", driver.browse(request)).await?;
671    Ok(Json(page.into()))
672}
673
674#[derive(Debug, Serialize, ToSchema)]
675pub struct OpcCloseBrowseSessionResponse {
676    pub closed: bool,
677}
678
679#[utoipa::path(
680    delete,
681    path = "/api/opc/browse/sessions/{session_id}",
682    tag = "opc",
683    params(
684        ("session_id" = String, Path, description = "Opaque bridge browse-session ID."),
685        OpcServerQuery
686    ),
687    responses(
688        (status = 200, body = OpcCloseBrowseSessionResponse),
689        (status = 400, description = "The browse session could not be closed.", body = ErrorBody),
690    ),
691)]
692pub(crate) async fn close_browse_session(
693    State(state): State<AppState>,
694    Path(session_id): Path<String>,
695    Query(query): Query<OpcServerQuery>,
696) -> Result<Json<OpcCloseBrowseSessionResponse>, ApiError> {
697    if session_id.trim().is_empty() {
698        return Err(ApiError::BadRequest(
699            "a browse session ID is required".to_string(),
700        ));
701    }
702    let config = state.config_snapshot()?;
703    let bridge_host = bhtune_cli::config::resolve_bridge_host(query.bridge_host, &config);
704    let opc_server = bhtune_cli::config::resolve_server(query.opc_server, &config)
705        .map_err(|err| ApiError::BadRequest(err.to_string()))?;
706    let driver = with_timeout(
707        &format!("connect to OPC server '{opc_server}' via bridge '{bridge_host}'"),
708        OpcDaDriver::connect(&bridge_host, opc_server),
709    )
710    .await?;
711    with_timeout(
712        "close OPC browse session",
713        driver.close_browse_session(&session_id),
714    )
715    .await?;
716    Ok(Json(OpcCloseBrowseSessionResponse { closed: true }))
717}
718
719/// Query parameters for the progressive `GET /api/opc/search` SSE endpoint.
720#[derive(Debug, Deserialize, IntoParams)]
721#[into_params(parameter_in = Query)]
722pub struct OpcSearchQuery {
723    pub bridge_host: Option<String>,
724    pub opc_server: Option<String>,
725    pub query: String,
726    #[serde(default = "default_search_match_mode")]
727    pub match_mode: String,
728    pub session_id: Option<String>,
729    pub scope_node_key: Option<String>,
730    #[serde(default = "default_search_max_results")]
731    #[param(minimum = 1)]
732    pub max_results: u32,
733    pub include_branches: Option<bool>,
734    pub refresh: Option<bool>,
735}
736
737fn default_search_match_mode() -> String {
738    "contains".to_string()
739}
740
741fn default_search_max_results() -> u32 {
742    bhtune_driver::DEFAULT_SEARCH_MAX_RESULTS
743}
744
745fn parse_search_match_mode(value: &str) -> Result<SearchMatchMode, ApiError> {
746    match value {
747        "exact" => Ok(SearchMatchMode::Exact),
748        "prefix" => Ok(SearchMatchMode::Prefix),
749        "contains" => Ok(SearchMatchMode::Contains),
750        _ => Err(ApiError::BadRequest(
751            "match_mode must be one of: exact, prefix, contains".to_string(),
752        )),
753    }
754}
755
756fn search_event_to_sse(event: SearchEvent) -> Event {
757    let (kind, payload) = match event {
758        SearchEvent::Match(found) => ("match", json_search_match(&found)),
759        SearchEvent::Progress(progress) => (
760            "progress",
761            serde_json::json!({
762                "visited_nodes": progress.visited_nodes,
763                "matches": progress.matches,
764                "partial": progress.partial,
765            }),
766        ),
767        SearchEvent::Completed(completed) => (
768            "completed",
769            serde_json::json!({
770                "complete": completed.complete,
771                "cancelled": completed.cancelled,
772                "truncated": completed.truncated,
773                "warning": completed.warning,
774            }),
775        ),
776    };
777    Event::default().event(kind).data(payload.to_string())
778}
779
780fn json_search_match(found: &SearchMatch) -> serde_json::Value {
781    serde_json::json!({
782        "node": {
783            "node_key": found.node.node_key,
784            "display_name": found.node.display_name,
785            "kind": browse_node_kind_name(found.node.kind),
786            "item_id": found.node.item_id,
787        },
788        "breadcrumbs": found.breadcrumbs.iter().map(|part| {
789            serde_json::json!({
790                "node_key": part.node_key,
791                "display_name": part.display_name,
792            })
793        }).collect::<Vec<_>>(),
794    })
795}
796
797fn browse_node_kind_name(kind: BrowseNodeKind) -> &'static str {
798    match kind {
799        BrowseNodeKind::Unspecified => "unspecified",
800        BrowseNodeKind::Branch => "branch",
801        BrowseNodeKind::Item => "item",
802        BrowseNodeKind::BranchAndItem => "branch_and_item",
803    }
804}
805
806#[utoipa::path(
807    get,
808    path = "/api/opc/search",
809    tag = "opc",
810    params(OpcSearchQuery),
811    responses(
812        (status = 200, description = "SSE stream of match, progress, and completed events."),
813        (status = 400, description = "The search request or gateway connection is invalid.", body = ErrorBody),
814    ),
815)]
816pub(crate) async fn search(
817    State(state): State<AppState>,
818    Query(query): Query<OpcSearchQuery>,
819) -> Result<Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>>, ApiError> {
820    if query.query.trim().is_empty() {
821        return Err(ApiError::BadRequest(
822            "a search query is required".to_string(),
823        ));
824    }
825    let match_mode = parse_search_match_mode(&query.match_mode)?;
826    let max_results = validate_positive(query.max_results, "max_results")?;
827    let config = state.config_snapshot()?;
828    let bridge_host = bhtune_cli::config::resolve_bridge_host(query.bridge_host, &config);
829    let opc_server = bhtune_cli::config::resolve_server(query.opc_server, &config)
830        .map_err(|err| ApiError::BadRequest(err.to_string()))?;
831    let driver = with_timeout(
832        &format!("connect to OPC server '{opc_server}' via bridge '{bridge_host}'"),
833        OpcDaDriver::connect(&bridge_host, opc_server.clone()),
834    )
835    .await?;
836    let request = SearchRequest {
837        query: query.query,
838        match_mode,
839        session_id: query.session_id,
840        scope_node_key: query.scope_node_key,
841        max_results,
842        include_branches: query.include_branches.unwrap_or(false),
843        refresh: query.refresh.unwrap_or(false),
844    };
845    let mut stream =
846        with_timeout("start OPC namespace search", driver.search_stream(request)).await?;
847    let events = async_stream::stream! {
848        loop {
849            match tokio::time::timeout(
850                Duration::from_secs(OPC_QUERY_TIMEOUT_SECS),
851                stream.next(),
852            )
853            .await
854            {
855                Ok(Ok(Some(event))) => yield Ok(search_event_to_sse(event)),
856                Ok(Ok(None)) => break,
857                Ok(Err(error)) => {
858                    yield Ok(Event::default().event("error").data(
859                        serde_json::json!({"error": error.to_string()}).to_string(),
860                    ));
861                    break;
862                }
863                Err(_) => {
864                    yield Ok(Event::default().event("error").data(
865                        serde_json::json!({
866                            "error": format!(
867                                "namespace search: no response within {OPC_QUERY_TIMEOUT_SECS}s"
868                            )
869                        }).to_string(),
870                    ));
871                    break;
872                }
873            }
874        }
875    };
876    Ok(Sse::new(events).keep_alive(KeepAlive::default()))
877}
878
879/// Query parameters for `GET /api/opc/read`.
880#[derive(Debug, Deserialize, IntoParams)]
881#[into_params(parameter_in = Query)]
882pub struct OpcReadQuery {
883    pub bridge_host: Option<String>,
884    pub opc_server: Option<String>,
885    /// The fully qualified tag to read (e.g. `"Unit1.LIC101.PV"`). Required -- unlike the
886    /// other two fields, there is no configured default for "which tag".
887    pub tag: Option<String>,
888}
889
890/// Response body of `GET /api/opc/read`.
891///
892/// `quality` reuses [`SampleQuality`] rather than a third quality representation --
893/// `bhtune-db`'s `SampleQuality` (mapped from the driver's live [`bhtune_driver::Quality`] by
894/// [`sample_quality_from_driver`]) is already exposed directly over HTTP in
895/// `GET /api/runs/{id}`'s `SampleResponse::pv_quality` (see `routes::history`), so this
896/// follows that same precedent instead of inventing a parallel `OpcQualityResponse` enum.
897#[derive(Debug, Serialize, ToSchema)]
898pub struct OpcReadResponse {
899    pub tag: String,
900    pub value: String,
901    pub quality: SampleQuality,
902    /// Always `null` for the OPC DA driver today: the gateway's last-change time is a
903    /// *local*, offset-less string with no reliable way to convert it to a trustworthy
904    /// `DateTime<Utc>` (see `bhtune_driver::opcda::tag_value_from_raw`'s doc comment) -- kept
905    /// as a field rather than dropped entirely so a future driver that *can* supply a
906    /// trustworthy instant (or a bridge protocol revision that reports the gateway's own
907    /// timezone) doesn't need an API shape change to start populating it.
908    pub timestamp: Option<DateTime<Utc>>,
909}
910
911/// Read one tag's current value, quality, and timestamp.
912///
913/// `GET /api/opc/read` -- backs the GUI's "Test connection" button (read the tag the user is
914/// about to use as the loop's PV and show what comes back) and the tag-tree's live preview.
915/// Deliberately does not enforce [`bhtune_driver::Quality::is_trustworthy`] the way a real
916/// tune's readings must -- this is a diagnostic command, so it reports whatever quality it
917/// gets rather than failing on `Uncertain`/`Bad`, matching `bhtune opc read`'s own behavior.
918#[utoipa::path(
919    get,
920    path = "/api/opc/read",
921    tag = "opc",
922    params(OpcReadQuery),
923    responses(
924        (status = 200, body = OpcReadResponse),
925        (status = 400, description = "No tag or OPC server was specified (and none is configured), or the gateway/read call could not be reached in time.", body = ErrorBody),
926    ),
927)]
928pub(crate) async fn read(
929    State(state): State<AppState>,
930    Query(query): Query<OpcReadQuery>,
931) -> Result<Json<OpcReadResponse>, ApiError> {
932    let tag = query
933        .tag
934        .filter(|t| !t.trim().is_empty())
935        .ok_or_else(|| ApiError::BadRequest("a tag is required".to_string()))?;
936    let config = state.config_snapshot()?;
937    let bridge_host = bhtune_cli::config::resolve_bridge_host(query.bridge_host, &config);
938    let opc_server = bhtune_cli::config::resolve_server(query.opc_server, &config)
939        .map_err(|err| ApiError::BadRequest(err.to_string()))?;
940    let driver = with_timeout(
941        &format!("connect to OPC server '{opc_server}' via bridge '{bridge_host}'"),
942        OpcDaDriver::connect(&bridge_host, opc_server.clone()),
943    )
944    .await?;
945    let values = with_timeout(
946        &format!("read '{tag}'"),
947        driver.read(std::slice::from_ref(&tag)),
948    )
949    .await?;
950    let value = values.into_iter().next().ok_or_else(|| {
951        ApiError::Internal(anyhow::anyhow!("driver returned no value for tag '{tag}'"))
952    })?;
953    Ok(Json(OpcReadResponse {
954        tag: value.tag,
955        value: value.value,
956        quality: sample_quality_from_driver(value.quality),
957        timestamp: value.timestamp,
958    }))
959}
960
961pub fn router() -> Router<AppState> {
962    Router::new()
963        .route("/api/opc/servers", get(servers))
964        .route("/api/opc/capabilities", get(capabilities))
965        .route("/api/opc/browse", get(browse))
966        .route(
967            "/api/opc/browse/sessions/{session_id}",
968            delete(close_browse_session),
969        )
970        .route("/api/opc/search", get(search))
971        .route("/api/opc/search-index/status", get(search_index_status))
972        .route("/api/opc/search-index/search", get(search_index))
973        .route("/api/opc/search-index/refresh", post(refresh_search_index))
974        .route(
975            "/api/opc/search-index/auto-refresh",
976            post(set_search_index_auto_refresh),
977        )
978        .route("/api/opc/search-index/control", post(control_search_index))
979        .route("/api/opc/search-index", delete(delete_search_index))
980        .route("/api/opc/read", get(read))
981}
982
983#[cfg(test)]
984mod tests {
985    use super::*;
986    use crate::test_support::mock_bridge::{MockBridgeService, start_mock_server};
987    use axum::body::{Body, to_bytes};
988    use axum::http::{Request, StatusCode};
989    use opcda_bridge_proto::bridge::{
990        BrowseNode as ProtoBrowseNode, BrowseNodeKind as ProtoBrowseNodeKind, BrowsePage,
991        BrowseSource as ProtoBrowseSource,
992        IndexSchedulerDiagnostics as ProtoIndexSchedulerDiagnostics,
993        IndexedSearchMatch as ProtoIndexedSearchMatch,
994        IndexedSearchProgress as ProtoIndexedSearchProgress, ListServersResponse,
995        NamespaceOrganization as ProtoNamespaceOrganization, ReadResponse,
996        SearchIndexResponse as ProtoSearchIndexResponse, SearchIndexState as ProtoSearchIndexState,
997        SearchIndexStatus as ProtoSearchIndexStatus, SearchMatchMode as ProtoSearchMatchMode,
998        TagValue as ProtoTagValue,
999    };
1000    use tonic::Code;
1001    use tower::ServiceExt;
1002
1003    async fn body_json(response: axum::http::Response<Body>) -> serde_json::Value {
1004        let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1005        serde_json::from_slice(&bytes).unwrap()
1006    }
1007
1008    async fn get(app: axum::Router, path: &str) -> axum::http::Response<Body> {
1009        app.oneshot(Request::get(path).body(Body::empty()).unwrap())
1010            .await
1011            .unwrap()
1012    }
1013
1014    async fn post(app: axum::Router, path: &str) -> axum::http::Response<Body> {
1015        app.oneshot(Request::post(path).body(Body::empty()).unwrap())
1016            .await
1017            .unwrap()
1018    }
1019
1020    async fn delete_request(app: axum::Router, path: &str) -> axum::http::Response<Body> {
1021        app.oneshot(Request::delete(path).body(Body::empty()).unwrap())
1022            .await
1023            .unwrap()
1024    }
1025
1026    fn proto_index_status(state: ProtoSearchIndexState) -> ProtoSearchIndexStatus {
1027        ProtoSearchIndexStatus {
1028            server: "Sim.Server".to_string(),
1029            state: state as i32,
1030            configured: true,
1031            active_generation: 7,
1032            entry_count: 12_345,
1033            unique_item_count: 9_876,
1034            started_at: Some("2026-08-16T10:00:00Z".to_string()),
1035            completed_at: Some("2026-08-16T10:05:00Z".to_string()),
1036            last_error: None,
1037            database_bytes: 65_536,
1038            organization: ProtoNamespaceOrganization::Hierarchical as i32,
1039            source: ProtoBrowseSource::Da3 as i32,
1040            effective_limits: None,
1041            controller_state: 0,
1042            pause_reason: None,
1043            recovery_deadline: None,
1044            foreground: Default::default(),
1045            host: Default::default(),
1046            storage: Default::default(),
1047            scheduler: Some(ProtoIndexSchedulerDiagnostics {
1048                next_refresh_at: Some("2026-08-23T10:05:00Z".to_string()),
1049                last_attempt_at: Some("2026-08-16T10:05:00Z".to_string()),
1050                last_success_at: Some("2026-08-16T10:05:00Z".to_string()),
1051                last_success_duration_ms: Some(300_000),
1052                retry_after: None,
1053                consecutive_failures: 0,
1054                circuit_open: false,
1055            }),
1056            health: Default::default(),
1057            promoting: false,
1058            pause_reason_detail: None,
1059            progress: Some(ProtoIndexedSearchProgress {
1060                branches_visited: 321,
1061                entries_seen: 12_345,
1062                unique_items: 9_876,
1063                active_time_ms: 240_000,
1064                paused_time_ms: 60_000,
1065                items_per_second: 250.5,
1066                estimated_remaining_ms: Some(30_000),
1067            }),
1068        }
1069    }
1070
1071    fn expect_bad_request(error: ApiError) -> String {
1072        match error {
1073            ApiError::BadRequest(message) => message,
1074            other => panic!("expected BadRequest, got {other:?}"),
1075        }
1076    }
1077
1078    #[test]
1079    fn opc_conversion_helpers_cover_all_enum_variants_and_sse_events() {
1080        for (wire, expected) in [
1081            (BrowseNodeKind::Unspecified, "unspecified"),
1082            (BrowseNodeKind::Branch, "branch"),
1083            (BrowseNodeKind::Item, "item"),
1084            (BrowseNodeKind::BranchAndItem, "branch_and_item"),
1085        ] {
1086            assert_eq!(browse_node_kind_name(wire), expected);
1087            let _ = OpcBrowseNodeKind::from(wire);
1088        }
1089        for (value, expected) in [
1090            (NamespaceOrganization::Unspecified, "unspecified"),
1091            (NamespaceOrganization::Flat, "flat"),
1092            (NamespaceOrganization::Hierarchical, "hierarchical"),
1093        ] {
1094            assert_eq!(organization_name(value), expected);
1095        }
1096        for (value, expected) in [
1097            (BrowseSource::Unspecified, "unspecified"),
1098            (BrowseSource::Da3, "da3"),
1099            (BrowseSource::Da2, "da2"),
1100            (BrowseSource::Flat, "flat"),
1101            (BrowseSource::Derived, "derived"),
1102        ] {
1103            assert_eq!(source_name(value), expected);
1104        }
1105        for value in ["exact", "prefix", "contains"] {
1106            assert!(parse_search_match_mode(value).is_ok());
1107        }
1108        let events = [
1109            SearchEvent::Match(SearchMatch {
1110                node: BrowseNode {
1111                    node_key: "n".into(),
1112                    display_name: "PV".into(),
1113                    kind: BrowseNodeKind::Item,
1114                    item_id: Some("Area.PV".into()),
1115                },
1116                breadcrumbs: vec![],
1117            }),
1118            SearchEvent::Progress(bhtune_driver::SearchProgress {
1119                visited_nodes: 2,
1120                matches: 1,
1121                partial: true,
1122            }),
1123            SearchEvent::Completed(bhtune_driver::SearchCompleted {
1124                complete: true,
1125                cancelled: false,
1126                truncated: false,
1127                warning: None,
1128            }),
1129        ];
1130        for event in events {
1131            let rendered = search_event_to_sse(event);
1132            assert!(!format!("{rendered:?}").is_empty());
1133        }
1134        let json = json_search_match(&SearchMatch {
1135            node: BrowseNode {
1136                node_key: "n".into(),
1137                display_name: "PV".into(),
1138                kind: BrowseNodeKind::BranchAndItem,
1139                item_id: Some("Area.PV".into()),
1140            },
1141            breadcrumbs: vec![bhtune_driver::BrowseBreadcrumb {
1142                node_key: "area".into(),
1143                display_name: "Area".into(),
1144            }],
1145        });
1146        assert_eq!(json["breadcrumbs"][0]["display_name"], "Area");
1147    }
1148
1149    /// A fresh in-memory [`AppState`] with `bridge_host`/`opc_server` overridden -- every
1150    /// test in this module needs the config resolution to pick up a specific mock gateway
1151    /// (or a deliberately unreachable/unset one), never the four seeded built-in templates.
1152    async fn state_with(bridge_host: Option<&str>, opc_server: Option<&str>) -> AppState {
1153        let state = crate::test_support::in_memory_state().await;
1154        let mut store = state.config_store.write().unwrap();
1155        store.config.bridge_host = bridge_host.map(str::to_string);
1156        store.config.server = opc_server.map(str::to_string);
1157        drop(store);
1158        state
1159    }
1160
1161    #[tokio::test]
1162    async fn servers_returns_every_registered_server_from_a_mock_gateway() {
1163        let host = start_mock_server(MockBridgeService {
1164            list_servers_response: ListServersResponse {
1165                servers: vec![
1166                    "Matrikon.OPC.Simulation.1".to_string(),
1167                    "Kepware.KEPServerEX.V6".to_string(),
1168                ],
1169            },
1170            ..Default::default()
1171        })
1172        .await;
1173        let app = crate::build_router(state_with(Some(&host), None).await);
1174
1175        let response = get(app, "/api/opc/servers").await;
1176        assert_eq!(response.status(), StatusCode::OK);
1177        let body = body_json(response).await;
1178        assert_eq!(
1179            body["servers"],
1180            serde_json::json!(["Matrikon.OPC.Simulation.1", "Kepware.KEPServerEX.V6"])
1181        );
1182    }
1183
1184    #[tokio::test]
1185    async fn servers_handles_an_empty_result() {
1186        let host = start_mock_server(MockBridgeService::default()).await;
1187        let app = crate::build_router(state_with(Some(&host), None).await);
1188
1189        let response = get(app, "/api/opc/servers").await;
1190        assert_eq!(response.status(), StatusCode::OK);
1191        assert_eq!(
1192            body_json(response).await,
1193            serde_json::json!({"servers": []})
1194        );
1195    }
1196
1197    #[tokio::test]
1198    async fn servers_returns_400_when_the_gateway_is_unreachable() {
1199        let app = crate::build_router(state_with(Some("127.0.0.1:1"), None).await);
1200
1201        let response = get(app, "/api/opc/servers").await;
1202        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1203        let body = body_json(response).await;
1204        assert!(
1205            body["error"]
1206                .as_str()
1207                .unwrap()
1208                .contains("list OPC DA servers")
1209        );
1210    }
1211
1212    #[tokio::test]
1213    async fn servers_query_param_overrides_the_configured_bridge_host() {
1214        let host = start_mock_server(MockBridgeService::default()).await;
1215        // Config points at an unreachable host; the query param must win.
1216        let app = crate::build_router(state_with(Some("127.0.0.1:1"), None).await);
1217
1218        let response = get(app, &format!("/api/opc/servers?bridge_host={host}")).await;
1219        assert_eq!(response.status(), StatusCode::OK);
1220    }
1221
1222    #[tokio::test]
1223    async fn capabilities_reports_the_indexed_search_contract() {
1224        let host = start_mock_server(MockBridgeService {
1225            capabilities_response: opcda_bridge_proto::bridge::GetCapabilitiesResponse {
1226                application_version: "0.4.0".to_string(),
1227                protocol_version: "2".to_string(),
1228                max_page_size: 1000,
1229                supports_browse_sessions: true,
1230                supports_search: true,
1231                organization: ProtoNamespaceOrganization::Hierarchical as i32,
1232                source: ProtoBrowseSource::Da3 as i32,
1233                supports_indexed_search: true,
1234                indexed_search_protocol_version: "1".to_string(),
1235                max_indexed_search_results: 50,
1236                search_index_state: ProtoSearchIndexState::Ready as i32,
1237                search_index_promoting: false,
1238            },
1239            ..Default::default()
1240        })
1241        .await;
1242        let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1243
1244        let response = get(app, "/api/opc/capabilities").await;
1245        assert_eq!(response.status(), StatusCode::OK);
1246        let body = body_json(response).await;
1247        assert_eq!(body["application_version"], "0.4.0");
1248        assert_eq!(body["protocol_version"], "2");
1249        assert_eq!(body["supports_indexed_search"], true);
1250        assert_eq!(body["indexed_search_protocol_version"], "1");
1251        assert_eq!(body["max_indexed_search_results"], 50);
1252        assert_eq!(body["search_index_state"], "ready");
1253    }
1254
1255    #[tokio::test]
1256    async fn search_index_status_maps_every_state_and_progress() {
1257        let states = [
1258            (ProtoSearchIndexState::Unspecified, "unspecified"),
1259            (ProtoSearchIndexState::NotIndexed, "not_indexed"),
1260            (ProtoSearchIndexState::Partial, "partial"),
1261            (ProtoSearchIndexState::Ready, "ready"),
1262            (ProtoSearchIndexState::Stale, "stale"),
1263            (ProtoSearchIndexState::Refreshing, "refreshing"),
1264            (ProtoSearchIndexState::Failed, "failed"),
1265        ];
1266
1267        for (state, expected_state) in states {
1268            let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1269            let host = start_mock_server(MockBridgeService {
1270                search_index_status_response: proto_index_status(state),
1271                search_index_status_requests: requests.clone(),
1272                ..Default::default()
1273            })
1274            .await;
1275            let app = crate::build_router(state_with(Some(&host), None).await);
1276
1277            let response = get(app, "/api/opc/search-index/status?opc_server=Sim.Server").await;
1278            assert_eq!(response.status(), StatusCode::OK);
1279            let body = body_json(response).await;
1280            assert_eq!(body["server"], "Sim.Server");
1281            assert_eq!(body["state"], expected_state);
1282            assert_eq!(body["auto_refresh_enabled"], true);
1283            assert_eq!(body["active_generation"], 7);
1284            assert_eq!(body["entry_count"], 12_345);
1285            assert_eq!(body["unique_item_count"], 9_876);
1286            assert_eq!(body["started_at"], "2026-08-16T10:00:00Z");
1287            assert_eq!(body["completed_at"], "2026-08-16T10:05:00Z");
1288            assert_eq!(body["database_bytes"], 65_536);
1289            assert_eq!(body["organization"], "hierarchical");
1290            assert_eq!(body["source"], "da3");
1291            assert_eq!(body["scheduler"]["next_refresh_at"], "2026-08-23T10:05:00Z");
1292            assert_eq!(body["scheduler"]["last_attempt_at"], "2026-08-16T10:05:00Z");
1293            assert_eq!(body["scheduler"]["last_success_at"], "2026-08-16T10:05:00Z");
1294            assert_eq!(body["scheduler"]["last_success_duration_ms"], 300_000);
1295            assert_eq!(body["scheduler"]["consecutive_failures"], 0);
1296            assert_eq!(body["scheduler"]["circuit_open"], false);
1297            assert_eq!(body["progress"]["branches_visited"], 321);
1298            assert_eq!(body["progress"]["entries_seen"], 12_345);
1299            assert_eq!(body["progress"]["unique_items"], 9_876);
1300            assert_eq!(body["progress"]["active_time_ms"], 240_000);
1301            assert_eq!(body["progress"]["paused_time_ms"], 60_000);
1302            assert_eq!(body["progress"]["items_per_second"], 250.5);
1303            assert_eq!(body["progress"]["estimated_remaining_ms"], 30_000);
1304            assert_eq!(
1305                requests.lock().unwrap().as_slice(),
1306                &[opcda_bridge_proto::bridge::GetSearchIndexStatusRequest {
1307                    server: "Sim.Server".to_string(),
1308                }]
1309            );
1310        }
1311    }
1312
1313    #[tokio::test]
1314    async fn search_index_returns_exact_matches_status_and_has_more() {
1315        let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1316        let host = start_mock_server(MockBridgeService {
1317            search_index_response: ProtoSearchIndexResponse {
1318                matches: vec![ProtoIndexedSearchMatch {
1319                    item_id: "FCS0201!204FI00510.PV".to_string(),
1320                    display_name: "PV".to_string(),
1321                    kind: ProtoBrowseNodeKind::BranchAndItem as i32,
1322                    breadcrumbs: vec!["FCS0201".to_string(), "204FI00510".to_string()],
1323                }],
1324                has_more: true,
1325                status: Some(proto_index_status(ProtoSearchIndexState::Ready)),
1326            },
1327            search_index_requests: requests.clone(),
1328            ..Default::default()
1329        })
1330        .await;
1331        let app = crate::build_router(state_with(Some(&host), None).await);
1332
1333        let response = get(
1334            app,
1335            "/api/opc/search-index/search?opc_server=Sim.Server&query=204FI00510&match_mode=prefix&max_results=7",
1336        )
1337        .await;
1338        assert_eq!(response.status(), StatusCode::OK);
1339        let body = body_json(response).await;
1340        assert_eq!(
1341            body["matches"],
1342            serde_json::json!([{
1343                "item_id": "FCS0201!204FI00510.PV",
1344                "display_name": "PV",
1345                "kind": "branch_and_item",
1346                "breadcrumbs": ["FCS0201", "204FI00510"],
1347            }])
1348        );
1349        assert_eq!(body["has_more"], true);
1350        assert_eq!(body["status"]["state"], "ready");
1351
1352        let request = &requests.lock().unwrap()[0];
1353        assert_eq!(request.server, "Sim.Server");
1354        assert_eq!(request.query, "204FI00510");
1355        assert_eq!(request.match_mode, ProtoSearchMatchMode::Prefix as i32);
1356        assert_eq!(request.max_results, 7);
1357    }
1358
1359    #[tokio::test]
1360    async fn search_index_refresh_and_control_forward_actions() {
1361        let refresh_requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1362        let control_requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1363        let host = start_mock_server(MockBridgeService {
1364            search_index_status_response: proto_index_status(ProtoSearchIndexState::Refreshing),
1365            refresh_search_index_requests: refresh_requests.clone(),
1366            control_search_index_requests: control_requests.clone(),
1367            ..Default::default()
1368        })
1369        .await;
1370
1371        let refresh_app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1372        let response = post(refresh_app, "/api/opc/search-index/refresh?force=true").await;
1373        assert_eq!(response.status(), StatusCode::OK);
1374        assert_eq!(body_json(response).await["state"], "refreshing");
1375        assert_eq!(
1376            refresh_requests.lock().unwrap().as_slice(),
1377            &[opcda_bridge_proto::bridge::RefreshSearchIndexRequest {
1378                server: "Sim.Server".to_string(),
1379                force: true,
1380            }]
1381        );
1382
1383        let control_app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1384        let response = post(control_app, "/api/opc/search-index/control?action=resume").await;
1385        assert_eq!(response.status(), StatusCode::OK);
1386        assert_eq!(body_json(response).await["state"], "refreshing");
1387        assert_eq!(
1388            control_requests.lock().unwrap().as_slice(),
1389            &[opcda_bridge_proto::bridge::ControlSearchIndexRequest {
1390                server: "Sim.Server".to_string(),
1391                action: opcda_bridge_proto::bridge::SearchIndexControlAction::Resume as i32,
1392            }]
1393        );
1394
1395        let auto_refresh_app =
1396            crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1397        let response = post(
1398            auto_refresh_app,
1399            "/api/opc/search-index/auto-refresh?enabled=false",
1400        )
1401        .await;
1402        assert_eq!(response.status(), StatusCode::OK);
1403        assert_eq!(body_json(response).await["state"], "refreshing");
1404
1405        let delete_app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1406        let response =
1407            delete_request(delete_app, "/api/opc/search-index?opc_server=Sim.Server").await;
1408        assert_eq!(response.status(), StatusCode::OK);
1409        assert_eq!(body_json(response).await["state"], "refreshing");
1410
1411        assert_eq!(
1412            control_requests.lock().unwrap().as_slice(),
1413            &[
1414                opcda_bridge_proto::bridge::ControlSearchIndexRequest {
1415                    server: "Sim.Server".to_string(),
1416                    action: opcda_bridge_proto::bridge::SearchIndexControlAction::Resume as i32,
1417                },
1418                opcda_bridge_proto::bridge::ControlSearchIndexRequest {
1419                    server: "Sim.Server".to_string(),
1420                    action: opcda_bridge_proto::bridge::SearchIndexControlAction::DisableAutoRefresh
1421                        as i32,
1422                },
1423                opcda_bridge_proto::bridge::ControlSearchIndexRequest {
1424                    server: "Sim.Server".to_string(),
1425                    action: opcda_bridge_proto::bridge::SearchIndexControlAction::Delete as i32,
1426                },
1427            ]
1428        );
1429    }
1430
1431    #[tokio::test]
1432    async fn indexed_search_routes_reject_invalid_input_before_connecting() {
1433        let cases = [
1434            (
1435                "/api/opc/search-index/search?query=%20&opc_server=Sim.Server",
1436                "search query is required",
1437            ),
1438            (
1439                "/api/opc/search-index/search?query=PV&match_mode=wildcard&opc_server=Sim.Server",
1440                "match_mode must be one of",
1441            ),
1442            (
1443                "/api/opc/search-index/search?query=PV&max_results=0&opc_server=Sim.Server",
1444                "max_results must be greater than zero",
1445            ),
1446            (
1447                "/api/opc/search-index/control?action=stop&opc_server=Sim.Server",
1448                "action must be one of",
1449            ),
1450        ];
1451
1452        for (path, expected_error) in cases {
1453            let app = crate::build_router(state_with(None, None).await);
1454            let response = if path.contains("/control") {
1455                post(app, path).await
1456            } else {
1457                get(app, path).await
1458            };
1459            assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1460            let body = body_json(response).await;
1461            assert!(body["error"].as_str().unwrap().contains(expected_error));
1462        }
1463    }
1464
1465    #[tokio::test]
1466    async fn indexed_search_routes_surface_gateway_errors() {
1467        let host = start_mock_server(MockBridgeService {
1468            search_index_status_error: Some(tonic::Status::internal("status failed")),
1469            search_index_error: Some(tonic::Status::internal("search failed")),
1470            refresh_search_index_error: Some(tonic::Status::internal("refresh failed")),
1471            control_search_index_error: Some(tonic::Status::internal("control failed")),
1472            ..Default::default()
1473        })
1474        .await;
1475        let paths = [
1476            (
1477                "/api/opc/search-index/status?opc_server=Sim.Server",
1478                "read OPC search-index status",
1479                false,
1480            ),
1481            (
1482                "/api/opc/search-index/search?query=PV&opc_server=Sim.Server",
1483                "search the OPC namespace index",
1484                false,
1485            ),
1486            (
1487                "/api/opc/search-index/refresh?opc_server=Sim.Server",
1488                "refresh the OPC namespace index",
1489                true,
1490            ),
1491            (
1492                "/api/opc/search-index/control?action=pause&opc_server=Sim.Server",
1493                "control the OPC namespace index",
1494                true,
1495            ),
1496            (
1497                "/api/opc/search-index/auto-refresh?enabled=false&opc_server=Sim.Server",
1498                "set OPC namespace index auto-refresh",
1499                true,
1500            ),
1501            (
1502                "/api/opc/search-index?opc_server=Sim.Server",
1503                "delete the OPC namespace index",
1504                false,
1505            ),
1506        ];
1507
1508        for (path, operation, is_post) in paths {
1509            let app = crate::build_router(state_with(Some(&host), None).await);
1510            let response = if path.starts_with("/api/opc/search-index?") {
1511                delete_request(app, path).await
1512            } else if is_post {
1513                post(app, path).await
1514            } else {
1515                get(app, path).await
1516            };
1517            assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1518            let error = body_json(response).await["error"]
1519                .as_str()
1520                .unwrap()
1521                .to_string();
1522            assert!(error.contains(operation), "{error}");
1523            assert!(error.contains("failed"), "{error}");
1524        }
1525    }
1526
1527    #[tokio::test]
1528    async fn indexed_search_status_reports_an_unsupported_gateway() {
1529        let host = start_mock_server(MockBridgeService {
1530            search_index_status_error: Some(tonic::Status::new(
1531                Code::Unimplemented,
1532                "indexed search is unavailable",
1533            )),
1534            ..Default::default()
1535        })
1536        .await;
1537        let app = crate::build_router(state_with(Some(&host), None).await);
1538
1539        let response = get(app, "/api/opc/search-index/status?opc_server=Sim.Server").await;
1540        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1541        let error = body_json(response).await["error"]
1542            .as_str()
1543            .unwrap()
1544            .to_string();
1545        assert!(error.contains("does not support indexed-search status"));
1546        assert!(
1547            error.contains("upgrade the OPC DA bridge gateway"),
1548            "{error}"
1549        );
1550    }
1551
1552    #[tokio::test]
1553    async fn browse_returns_nodes_from_a_mock_gateway() {
1554        let host = start_mock_server(MockBridgeService {
1555            browse_response: BrowsePage {
1556                session_id: "session".to_string(),
1557                nodes: vec![ProtoBrowseNode {
1558                    node_key: "unit1".to_string(),
1559                    display_name: "Unit1".to_string(),
1560                    kind: ProtoBrowseNodeKind::Branch as i32,
1561                    item_id: None,
1562                }],
1563                complete: true,
1564                ..Default::default()
1565            },
1566            ..Default::default()
1567        })
1568        .await;
1569        let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1570
1571        let response = get(app, "/api/opc/browse").await;
1572        assert_eq!(response.status(), StatusCode::OK);
1573        let body = body_json(response).await;
1574        assert_eq!(body["session_id"], "session");
1575        assert_eq!(body["nodes"][0]["display_name"], "Unit1");
1576        assert_eq!(body["nodes"][0]["kind"], "branch");
1577    }
1578
1579    #[tokio::test]
1580    async fn browse_handles_an_empty_result() {
1581        let host = start_mock_server(MockBridgeService::default()).await;
1582        let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1583
1584        let response = get(
1585            app,
1586            "/api/opc/browse?session_id=session&parent_node_key=unit1",
1587        )
1588        .await;
1589        assert_eq!(response.status(), StatusCode::OK);
1590        let body = body_json(response).await;
1591        assert_eq!(body["nodes"], serde_json::json!([]));
1592        assert_eq!(body["complete"], true);
1593    }
1594
1595    #[tokio::test]
1596    async fn browse_returns_400_when_no_server_is_configured() {
1597        let app = crate::build_router(state_with(None, None).await);
1598
1599        let response = get(app, "/api/opc/browse").await;
1600        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1601        let body = body_json(response).await;
1602        assert!(
1603            body["error"]
1604                .as_str()
1605                .unwrap()
1606                .contains("no OPC server specified")
1607        );
1608    }
1609
1610    #[tokio::test]
1611    async fn browse_returns_400_when_the_gateway_is_unreachable() {
1612        let app = crate::build_router(state_with(Some("127.0.0.1:1"), Some("Sim.Server")).await);
1613
1614        let response = get(app, "/api/opc/browse").await;
1615        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1616        let body = body_json(response).await;
1617        assert!(
1618            body["error"]
1619                .as_str()
1620                .unwrap()
1621                .contains("connect to OPC server")
1622        );
1623    }
1624
1625    #[tokio::test]
1626    async fn close_browse_session_forwards_the_opaque_session_id() {
1627        let host = start_mock_server(MockBridgeService::default()).await;
1628        let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1629        let response = get(
1630            app,
1631            "/api/opc/browse/sessions/session-42?opc_server=Sim.Server",
1632        )
1633        .await;
1634        // A GET is intentionally rejected by the route method; exercise the real DELETE below.
1635        assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
1636        let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1637        let response = app
1638            .oneshot(
1639                Request::delete("/api/opc/browse/sessions/session-42?opc_server=Sim.Server")
1640                    .body(Body::empty())
1641                    .unwrap(),
1642            )
1643            .await
1644            .unwrap();
1645        assert_eq!(response.status(), StatusCode::OK);
1646        assert_eq!(body_json(response).await["closed"], true);
1647    }
1648
1649    #[tokio::test]
1650    async fn search_stream_returns_match_progress_and_completed_events() {
1651        let host = start_mock_server(MockBridgeService {
1652            search_events: vec![
1653                opcda_bridge_proto::bridge::SearchEvent {
1654                    event: Some(opcda_bridge_proto::bridge::search_event::Event::Match(
1655                        opcda_bridge_proto::bridge::SearchMatch {
1656                            node: Some(ProtoBrowseNode {
1657                                node_key: "pv".into(),
1658                                display_name: "PV".into(),
1659                                kind: ProtoBrowseNodeKind::Item as i32,
1660                                item_id: Some("Area.PV".into()),
1661                            }),
1662                            breadcrumbs: vec![opcda_bridge_proto::bridge::BrowseBreadcrumb {
1663                                node_key: "area".into(),
1664                                display_name: "Area".into(),
1665                            }],
1666                        },
1667                    )),
1668                },
1669                opcda_bridge_proto::bridge::SearchEvent {
1670                    event: Some(opcda_bridge_proto::bridge::search_event::Event::Progress(
1671                        opcda_bridge_proto::bridge::SearchProgress {
1672                            visited_nodes: 3,
1673                            matches: 1,
1674                            partial: false,
1675                        },
1676                    )),
1677                },
1678                opcda_bridge_proto::bridge::SearchEvent {
1679                    event: Some(opcda_bridge_proto::bridge::search_event::Event::Completed(
1680                        opcda_bridge_proto::bridge::SearchCompleted {
1681                            complete: true,
1682                            cancelled: false,
1683                            truncated: false,
1684                            warning: None,
1685                        },
1686                    )),
1687                },
1688            ],
1689            ..Default::default()
1690        })
1691        .await;
1692        let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1693        let response = get(
1694            app,
1695            "/api/opc/search?opc_server=Sim.Server&query=PV&match_mode=exact&max_results=4",
1696        )
1697        .await;
1698        assert_eq!(response.status(), StatusCode::OK);
1699        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1700        let text = String::from_utf8(body.to_vec()).unwrap();
1701        assert!(text.contains("event: match"));
1702        assert!(text.contains("event: progress"));
1703        assert!(text.contains("event: completed"));
1704        assert!(text.contains("Area.PV"));
1705    }
1706
1707    #[tokio::test]
1708    async fn read_returns_the_value_quality_and_timestamp_from_a_mock_gateway() {
1709        let host = start_mock_server(MockBridgeService {
1710            // Constructed directly (rather than via `good_reading`, which hardcodes an
1711            // `"ignored"` tag_id -- fine for `runs.rs`'s tests, which don't echo it back, but
1712            // this handler does) so the mocked response's tag_id matches what was requested,
1713            // matching `bhtune-cli`'s own `read_prints_values_from_a_mock_gateway` precedent.
1714            read_response: ReadResponse {
1715                values: vec![ProtoTagValue {
1716                    tag_id: "Unit1.LIC101.PV".to_string(),
1717                    value: "42.5".to_string(),
1718                    quality: "Good".to_string(),
1719                    timestamp: "2024-01-15 10:23:45".to_string(),
1720                }],
1721            },
1722            ..Default::default()
1723        })
1724        .await;
1725        let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1726
1727        let response = get(app, "/api/opc/read?tag=Unit1.LIC101.PV").await;
1728        assert_eq!(response.status(), StatusCode::OK);
1729        let body = body_json(response).await;
1730        assert_eq!(body["tag"], "Unit1.LIC101.PV");
1731        assert_eq!(body["value"], "42.5");
1732        assert_eq!(body["quality"], "good");
1733        // Always `null` today -- the OPC DA driver never trusts the gateway's local,
1734        // offset-less timestamp string enough to convert it (see `OpcReadResponse::timestamp`'s
1735        // doc comment); asserting `is_null()` here (not `is_string()`) documents that as
1736        // deliberate rather than a bug were someone to "fix" it later without reading why.
1737        assert!(body["timestamp"].is_null());
1738    }
1739
1740    #[tokio::test]
1741    async fn read_returns_500_when_the_driver_reports_success_but_no_value() {
1742        // A well-behaved driver never does this for a single requested tag -- but the mock
1743        // gateway's `read` RPC ignores the actual request and returns whatever
1744        // `read_response` is configured with (see `test_support::mock_bridge`'s `read`
1745        // implementation), which makes this defensive `ApiError::Internal` branch (a bug in
1746        // the driver, not a client mistake) reachable in a test without needing a real
1747        // misbehaving gateway.
1748        let host = start_mock_server(MockBridgeService {
1749            read_response: ReadResponse { values: vec![] },
1750            ..Default::default()
1751        })
1752        .await;
1753        let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1754
1755        let response = get(app, "/api/opc/read?tag=Unit1.LIC101.PV").await;
1756        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
1757        let body = body_json(response).await;
1758        // The tag name and "driver returned no value" detail are logged (`tracing::error!`
1759        // in `error.rs`'s `IntoResponse` impl), never sent to the client -- matching every
1760        // other `ApiError::Internal` response in this codebase.
1761        assert_eq!(body["error"], "internal server error");
1762    }
1763
1764    #[tokio::test]
1765    async fn read_requires_a_tag() {
1766        let app = crate::build_router(state_with(None, Some("Sim.Server")).await);
1767
1768        let response = get(app, "/api/opc/read").await;
1769        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1770        let body = body_json(response).await;
1771        assert!(body["error"].as_str().unwrap().contains("tag is required"));
1772    }
1773
1774    #[tokio::test]
1775    async fn read_returns_400_when_no_server_is_configured() {
1776        let app = crate::build_router(state_with(None, None).await);
1777
1778        let response = get(app, "/api/opc/read?tag=Unit1.LIC101.PV").await;
1779        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1780        let body = body_json(response).await;
1781        assert!(
1782            body["error"]
1783                .as_str()
1784                .unwrap()
1785                .contains("no OPC server specified")
1786        );
1787    }
1788
1789    #[tokio::test]
1790    async fn read_returns_400_when_the_gateway_is_unreachable() {
1791        let app = crate::build_router(state_with(Some("127.0.0.1:1"), Some("Sim.Server")).await);
1792
1793        let response = get(app, "/api/opc/read?tag=Unit1.LIC101.PV").await;
1794        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1795    }
1796
1797    #[tokio::test]
1798    async fn close_browse_session_validates_empty_ids_before_connecting() {
1799        let app = crate::build_router(state_with(None, Some("Sim.Server")).await);
1800        let response = app
1801            .oneshot(
1802                Request::delete("/api/opc/browse/sessions/%20?opc_server=Sim.Server")
1803                    .body(Body::empty())
1804                    .unwrap(),
1805            )
1806            .await
1807            .unwrap();
1808        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1809        assert!(
1810            body_json(response).await["error"]
1811                .as_str()
1812                .unwrap()
1813                .contains("session ID")
1814        );
1815    }
1816
1817    #[tokio::test]
1818    async fn search_validates_empty_query_before_connecting() {
1819        let app = crate::build_router(state_with(None, Some("Sim.Server")).await);
1820        let response = get(app, "/api/opc/search?query=%20").await;
1821        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1822        assert!(
1823            body_json(response).await["error"]
1824                .as_str()
1825                .unwrap()
1826                .contains("search query")
1827        );
1828    }
1829
1830    #[tokio::test]
1831    async fn read_returns_400_when_the_connected_gateway_rejects_the_read() {
1832        let host = start_mock_server(MockBridgeService {
1833            read_error: Some(tonic::Status::unavailable("read unavailable")),
1834            ..Default::default()
1835        })
1836        .await;
1837        let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1838
1839        let response = get(app, "/api/opc/read?tag=Unit1.LIC101.PV").await;
1840        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1841        assert!(
1842            body_json(response).await["error"]
1843                .as_str()
1844                .unwrap()
1845                .contains("read 'Unit1.LIC101.PV'")
1846        );
1847    }
1848
1849    #[tokio::test]
1850    async fn read_surfaces_uncertain_and_bad_quality_without_failing() {
1851        let host = start_mock_server(MockBridgeService {
1852            read_response: opcda_bridge_proto::bridge::ReadResponse {
1853                values: vec![opcda_bridge_proto::bridge::TagValue {
1854                    tag_id: "ignored".to_string(),
1855                    value: "0".to_string(),
1856                    quality: "Bad".to_string(),
1857                    timestamp: "2024-01-15 10:23:45".to_string(),
1858                }],
1859            },
1860            ..Default::default()
1861        })
1862        .await;
1863        let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1864
1865        let response = get(app, "/api/opc/read?tag=Unit1.LIC101.PV").await;
1866        assert_eq!(response.status(), StatusCode::OK);
1867        let body = body_json(response).await;
1868        assert_eq!(body["quality"], "bad");
1869    }
1870
1871    /// Direct unit test of the shared timeout wrapper (rather than driving a full HTTP
1872    /// handler through a real stalled gateway call) -- mirrors `bhtune-cli`'s own
1873    /// `bounded_driver_call_returns_timed_out_when_the_driver_call_stalls` test precedent:
1874    /// `start_paused = true` lets `tokio::time::timeout`'s deadline elapse in virtual rather
1875    /// than real time, so this proves the elapsed-deadline branch without an actual 30s wait.
1876    #[tokio::test(start_paused = true)]
1877    async fn with_timeout_maps_an_elapsed_deadline_to_a_bad_request() {
1878        let err = with_timeout("read 'X'", std::future::pending::<DriverResult<()>>())
1879            .await
1880            .unwrap_err();
1881        let message = expect_bad_request(err);
1882        assert!(message.contains("read 'X'"));
1883        assert!(message.contains("no response within 30s"));
1884    }
1885
1886    #[tokio::test]
1887    async fn with_timeout_passes_through_a_successful_result() {
1888        let value = with_timeout("op", async { Ok::<_, bhtune_driver::DriverError>(7) })
1889            .await
1890            .unwrap();
1891        assert_eq!(value, 7);
1892    }
1893
1894    #[tokio::test]
1895    async fn with_timeout_maps_a_driver_error_to_a_bad_request() {
1896        let err = with_timeout("read 'X'", async {
1897            Err::<(), _>(bhtune_driver::DriverError::Unsupported {
1898                operation: "browse",
1899            })
1900        })
1901        .await
1902        .unwrap_err();
1903        let message = expect_bad_request(err);
1904        assert!(message.contains("read 'X'"));
1905        assert!(message.contains("not supported"));
1906    }
1907
1908    #[test]
1909    fn bad_request_assertion_fails_clearly_for_another_api_error() {
1910        let panic = std::panic::catch_unwind(|| {
1911            expect_bad_request(ApiError::NotFound("missing".to_string()))
1912        })
1913        .unwrap_err();
1914        assert!(
1915            panic
1916                .downcast_ref::<String>()
1917                .is_some_and(|message| message.contains("BadRequest"))
1918        );
1919    }
1920
1921    #[tokio::test]
1922    async fn with_timeout_preserves_index_enrollment_diagnostic() {
1923        let err = with_timeout("refresh the OPC namespace index", async {
1924            Err::<(), _>(bhtune_driver::DriverError::IndexOperationRejected {
1925                message: "server is not enrolled for namespace indexing".to_string(),
1926            })
1927        })
1928        .await
1929        .unwrap_err();
1930        match err {
1931            ApiError::BadRequest(message) => {
1932                assert!(message.contains("refresh the OPC namespace index"));
1933                assert!(message.contains("server is not enrolled for namespace indexing"));
1934            }
1935            other => panic!("expected BadRequest, got {other:?}"),
1936        }
1937    }
1938}