Skip to main content

axonflow_sdk_rust/
hitl.rs

1// HITL (Human-in-the-Loop) Queue methods for the AxonFlow Rust SDK.
2//
3// Implements the full HITL surface added in v0.4.0
4// (getaxonflow/axonflow-enterprise#2421 — Rust was the only SDK
5// without ANY HITL methods, so this PR is the entire 6-method surface
6// rather than just the create-method delta the other four SDKs ship).
7//
8// Method shape mirrors the cross-SDK contract used in Python /
9// TypeScript / Go / Java:
10//
11//   list_hitl_queue(opts) -> HITLQueueListResponse
12//   get_hitl_request(request_id) -> HITLApprovalRequest
13//   create_hitl_request(input) -> HITLApprovalRequest          [#2421]
14//   approve_hitl_request(request_id, review) -> ()
15//   reject_hitl_request(request_id, review) -> ()
16//   get_hitl_stats() -> HITLStats
17
18use crate::client::{AxonFlowClient, PATH_SEGMENT};
19use crate::error::AxonFlowError;
20use crate::types::hitl::{
21    HITLApprovalRequest, HITLCreateInput, HITLQueueListOptions, HITLQueueListResponse,
22    HITLReviewInput, HITLStats, HitlItemEnvelope, HitlListEnvelope, HitlStatsEnvelope,
23};
24use percent_encoding::utf8_percent_encode;
25
26impl AxonFlowClient {
27    /// Lists approval requests in the HITL queue.
28    ///
29    /// Enterprise Feature: Requires AxonFlow Enterprise license.
30    ///
31    /// # Example
32    ///
33    /// ```no_run
34    /// # use axonflow_sdk_rust::{AxonFlowClient, AxonFlowConfig, HITLQueueListOptions};
35    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
36    /// let client = AxonFlowClient::new(AxonFlowConfig::new("http://localhost:8080"))?;
37    /// let opts = HITLQueueListOptions {
38    ///     status: Some("pending".into()),
39    ///     severity: Some("high,critical".into()),
40    ///     limit: Some(20),
41    ///     ..Default::default()
42    /// };
43    /// let page = client.list_hitl_queue(opts).await?;
44    /// println!("{} of {} pending HITL approvals", page.items.len(), page.total);
45    /// # Ok(()) }
46    /// ```
47    pub async fn list_hitl_queue(
48        &self,
49        opts: HITLQueueListOptions,
50    ) -> Result<HITLQueueListResponse, AxonFlowError> {
51        let mut url = format!("{}/api/v1/hitl/queue", self.endpoint());
52        let qs = build_list_query(&opts);
53        if !qs.is_empty() {
54            url.push('?');
55            url.push_str(&qs);
56        }
57
58        let resp = self.checked_get(&url).await?;
59        let body = resp.text().await?;
60        let envelope: HitlListEnvelope = serde_json::from_str(&body)?;
61        let total = envelope.meta.total;
62        let returned = envelope.data.len() as i64;
63        let offset = envelope.meta.offset;
64        Ok(HITLQueueListResponse {
65            items: envelope.data,
66            total,
67            has_more: offset + returned < total,
68        })
69    }
70
71    /// Gets a specific HITL approval request by ID.
72    ///
73    /// Enterprise Feature: Requires AxonFlow Enterprise license.
74    pub async fn get_hitl_request(
75        &self,
76        request_id: &str,
77    ) -> Result<HITLApprovalRequest, AxonFlowError> {
78        if request_id.is_empty() {
79            return Err(AxonFlowError::ConfigError(
80                "request_id is required".to_string(),
81            ));
82        }
83        let encoded = utf8_percent_encode(request_id, PATH_SEGMENT).to_string();
84        let url = format!("{}/api/v1/hitl/queue/{}", self.endpoint(), encoded);
85
86        let resp = self.checked_get(&url).await?;
87        let body = resp.text().await?;
88        let envelope: HitlItemEnvelope = serde_json::from_str(&body)?;
89        Ok(envelope.data)
90    }
91
92    /// Creates a new HITL approval request via `POST /api/v1/hitl/queue`.
93    ///
94    /// Enterprise Feature: Requires AxonFlow Enterprise license. The
95    /// platform returns 403 with `ErrHITLApprovalDisabledByTier` when
96    /// called against a community tier that hasn't enabled HITL, and
97    /// 401 when credentials are invalid.
98    ///
99    /// This is the explicit row-creation step for callers that detect
100    /// `require_approval` from a separate gate (`pre_check`,
101    /// `check_tool_input`, MAP plan approvals) and want the row
102    /// enqueued so a reviewer can act on it. After creating, either
103    /// poll [`get_hitl_request`](Self::get_hitl_request) until terminal
104    /// state, or supply
105    /// [`HITLCreateInput::notify_url`](crate::HITLCreateInput::notify_url)
106    /// so the platform fires a signed webhook on the transition (n8n
107    /// Wait-node "On Webhook Call" pattern, ADK plugin polling-free
108    /// mode).
109    ///
110    /// `client_id`, `original_query`, and `request_type` are required;
111    /// all other fields are optional. Bad `notify_url` schemes are
112    /// rejected by the platform with HTTP 400 (surfaced here as
113    /// [`AxonFlowError::ApiError`] with `status = 400`); only `https://`
114    /// (and `http://` for self-hosted local-dev) are accepted.
115    ///
116    /// # Example
117    ///
118    /// ```no_run
119    /// # use axonflow_sdk_rust::{AxonFlowClient, AxonFlowConfig, HITLCreateInput};
120    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
121    /// let client = AxonFlowClient::new(AxonFlowConfig::new("http://localhost:8080"))?;
122    /// let req = client
123    ///     .create_hitl_request(HITLCreateInput {
124    ///         client_id: "loan-desk".into(),
125    ///         original_query: "disburse $50000 to cust-001".into(),
126    ///         request_type: "adk-tool".into(),
127    ///         triggered_policy_id: Some("loan-amount-cap".into()),
128    ///         triggered_policy_name: Some("Loan amount cap".into()),
129    ///         trigger_reason: Some(
130    ///             "Disbursement above $10k requires manager approval".into(),
131    ///         ),
132    ///         severity: Some("high".into()),
133    ///         notify_url: Some(
134    ///             "https://workflows.example.com/hooks/loan-approve".into(),
135    ///         ),
136    ///         ..Default::default()
137    ///     })
138    ///     .await?;
139    /// println!("Created HITL approval {}", req.request_id);
140    /// # Ok(()) }
141    /// ```
142    pub async fn create_hitl_request(
143        &self,
144        input: HITLCreateInput,
145    ) -> Result<HITLApprovalRequest, AxonFlowError> {
146        if input.client_id.is_empty() {
147            return Err(AxonFlowError::ConfigError(
148                "client_id is required".to_string(),
149            ));
150        }
151        if input.original_query.is_empty() {
152            return Err(AxonFlowError::ConfigError(
153                "original_query is required".to_string(),
154            ));
155        }
156        if input.request_type.is_empty() {
157            return Err(AxonFlowError::ConfigError(
158                "request_type is required".to_string(),
159            ));
160        }
161
162        let url = format!("{}/api/v1/hitl/queue", self.endpoint());
163        let resp = self.checked_post_json(&url, &input).await?;
164        let body = resp.text().await?;
165        let envelope: HitlItemEnvelope = serde_json::from_str(&body)?;
166        Ok(envelope.data)
167    }
168
169    /// Approves a pending HITL approval request.
170    ///
171    /// Enterprise Feature: Requires AxonFlow Enterprise license.
172    pub async fn approve_hitl_request(
173        &self,
174        request_id: &str,
175        review: HITLReviewInput,
176    ) -> Result<(), AxonFlowError> {
177        self.review_hitl_request(request_id, "approve", &review)
178            .await
179    }
180
181    /// Rejects a pending HITL approval request.
182    ///
183    /// Enterprise Feature: Requires AxonFlow Enterprise license.
184    pub async fn reject_hitl_request(
185        &self,
186        request_id: &str,
187        review: HITLReviewInput,
188    ) -> Result<(), AxonFlowError> {
189        self.review_hitl_request(request_id, "reject", &review)
190            .await
191    }
192
193    /// Gets HITL queue dashboard statistics.
194    ///
195    /// Enterprise Feature: Requires AxonFlow Enterprise license.
196    pub async fn get_hitl_stats(&self) -> Result<HITLStats, AxonFlowError> {
197        let url = format!("{}/api/v1/hitl/stats", self.endpoint());
198        let resp = self.checked_get(&url).await?;
199        let body = resp.text().await?;
200        let envelope: HitlStatsEnvelope = serde_json::from_str(&body)?;
201        Ok(envelope.data)
202    }
203
204    /// Shared helper for the approve/reject endpoints — same shape, same
205    /// error mapping, distinct path suffix.
206    async fn review_hitl_request(
207        &self,
208        request_id: &str,
209        action: &str,
210        review: &HITLReviewInput,
211    ) -> Result<(), AxonFlowError> {
212        if request_id.is_empty() {
213            return Err(AxonFlowError::ConfigError(
214                "request_id is required".to_string(),
215            ));
216        }
217        let encoded = utf8_percent_encode(request_id, PATH_SEGMENT).to_string();
218        let url = format!(
219            "{}/api/v1/hitl/queue/{}/{}",
220            self.endpoint(),
221            encoded,
222            action
223        );
224        let _ = self.checked_post_json(&url, review).await?;
225        Ok(())
226    }
227}
228
229/// Builds the URL-encoded query string from [`HITLQueueListOptions`].
230/// Empty/`None` fields are omitted so the platform applies its tier
231/// defaults. Field order is stable so test mocks can match the URL.
232fn build_list_query(opts: &HITLQueueListOptions) -> String {
233    let mut pairs: Vec<(&str, String)> = Vec::with_capacity(4);
234    if let Some(status) = &opts.status {
235        pairs.push(("status", status.clone()));
236    }
237    if let Some(severity) = &opts.severity {
238        pairs.push(("severity", severity.clone()));
239    }
240    if let Some(limit) = opts.limit {
241        pairs.push(("limit", limit.to_string()));
242    }
243    if let Some(offset) = opts.offset {
244        pairs.push(("offset", offset.to_string()));
245    }
246    pairs
247        .into_iter()
248        .map(|(k, v)| {
249            let v = utf8_percent_encode(&v, PATH_SEGMENT).to_string();
250            format!("{k}={v}")
251        })
252        .collect::<Vec<_>>()
253        .join("&")
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::{AxonFlowClient, AxonFlowConfig};
260    use serde_json::json;
261    use std::time::Duration;
262    use wiremock::matchers::{body_partial_json, method, path, query_param};
263    use wiremock::{Mock, MockServer, ResponseTemplate};
264
265    fn make_client(endpoint: String) -> AxonFlowClient {
266        let config = AxonFlowConfig {
267            endpoint,
268            timeout: Duration::from_secs(2),
269            ..Default::default()
270        };
271        AxonFlowClient::new(config).expect("client init")
272    }
273
274    fn sample_row() -> serde_json::Value {
275        json!({
276            "request_id": "hitl-req-runtime-001",
277            "org_id": "org-1",
278            "tenant_id": "tenant-1",
279            "client_id": "loan-desk",
280            "user_id": "cust-001",
281            "original_query": "disburse $50000 to cust-001",
282            "request_type": "adk-tool",
283            "request_context": {"tool_name": "disburse_payment"},
284            "triggered_policy_id": "loan-amount-cap",
285            "triggered_policy_name": "Loan amount cap",
286            "trigger_reason": "Disbursement above $10k requires manager approval",
287            "severity": "high",
288            "status": "pending",
289            "notify_url": "https://workflows.example.com/hooks/loan-approve",
290            "expires_at": "2026-05-23T11:00:00Z",
291            "created_at": "2026-05-23T10:00:00Z",
292            "updated_at": "2026-05-23T10:00:00Z",
293        })
294    }
295
296    // ---- list ----
297
298    #[tokio::test]
299    async fn list_happy_path_parses_payload_and_pagination() {
300        let server = MockServer::start().await;
301        Mock::given(method("GET"))
302            .and(path("/api/v1/hitl/queue"))
303            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
304                "success": true,
305                "data": [sample_row()],
306                "meta": {"total": 1, "limit": 50, "offset": 0},
307            })))
308            .mount(&server)
309            .await;
310
311        let client = make_client(server.uri());
312        let page = client
313            .list_hitl_queue(HITLQueueListOptions::default())
314            .await
315            .unwrap();
316
317        assert_eq!(page.total, 1);
318        assert_eq!(page.items.len(), 1);
319        assert!(!page.has_more);
320        assert_eq!(page.items[0].request_id, "hitl-req-runtime-001");
321        assert_eq!(
322            page.items[0].notify_url.as_deref(),
323            Some("https://workflows.example.com/hooks/loan-approve")
324        );
325    }
326
327    #[tokio::test]
328    async fn list_passes_filters_via_query_string() {
329        let server = MockServer::start().await;
330        Mock::given(method("GET"))
331            .and(path("/api/v1/hitl/queue"))
332            .and(query_param("status", "pending"))
333            .and(query_param("severity", "critical"))
334            .and(query_param("limit", "5"))
335            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
336                "success": true,
337                "data": [],
338                "meta": {"total": 0, "limit": 5, "offset": 0},
339            })))
340            .expect(1)
341            .mount(&server)
342            .await;
343
344        let client = make_client(server.uri());
345        let opts = HITLQueueListOptions {
346            status: Some("pending".into()),
347            severity: Some("critical".into()),
348            limit: Some(5),
349            offset: None,
350        };
351        let _ = client.list_hitl_queue(opts).await.unwrap();
352    }
353
354    // ---- get ----
355
356    #[tokio::test]
357    async fn get_happy_path_parses_full_row() {
358        let server = MockServer::start().await;
359        Mock::given(method("GET"))
360            .and(path("/api/v1/hitl/queue/hitl-req-runtime-001"))
361            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
362                "success": true,
363                "data": sample_row(),
364            })))
365            .mount(&server)
366            .await;
367
368        let client = make_client(server.uri());
369        let got = client
370            .get_hitl_request("hitl-req-runtime-001")
371            .await
372            .unwrap();
373        assert_eq!(got.request_id, "hitl-req-runtime-001");
374        assert_eq!(got.severity, "high");
375        assert_eq!(
376            got.notify_url.as_deref(),
377            Some("https://workflows.example.com/hooks/loan-approve")
378        );
379    }
380
381    #[tokio::test]
382    async fn get_empty_id_returns_config_error() {
383        let client = make_client("http://127.0.0.1:1".into());
384        let err = client.get_hitl_request("").await.unwrap_err();
385        assert!(err.to_string().contains("request_id is required"));
386    }
387
388    #[tokio::test]
389    async fn get_404_surfaces_as_api_error() {
390        let server = MockServer::start().await;
391        Mock::given(method("GET"))
392            .and(path("/api/v1/hitl/queue/nope"))
393            .respond_with(ResponseTemplate::new(404).set_body_json(json!({"error": "not found"})))
394            .mount(&server)
395            .await;
396
397        let client = make_client(server.uri());
398        let err = client.get_hitl_request("nope").await.unwrap_err();
399        match err {
400            AxonFlowError::ApiError { status, .. } => assert_eq!(status, 404),
401            other => panic!("expected ApiError(404), got {other}"),
402        }
403    }
404
405    // ---- create ----
406
407    #[tokio::test]
408    async fn create_happy_path_round_trips_full_input() {
409        let server = MockServer::start().await;
410        Mock::given(method("POST"))
411            .and(path("/api/v1/hitl/queue"))
412            .and(body_partial_json(json!({
413                "client_id": "loan-desk",
414                "original_query": "disburse $50000 to cust-001",
415                "request_type": "adk-tool",
416                "notify_url": "https://workflows.example.com/hooks/loan-approve",
417                "severity": "high",
418            })))
419            .respond_with(ResponseTemplate::new(201).set_body_json(json!({
420                "success": true,
421                "data": sample_row(),
422            })))
423            .expect(1)
424            .mount(&server)
425            .await;
426
427        let client = make_client(server.uri());
428        let req = client
429            .create_hitl_request(HITLCreateInput {
430                client_id: "loan-desk".into(),
431                user_id: Some("cust-001".into()),
432                original_query: "disburse $50000 to cust-001".into(),
433                request_type: "adk-tool".into(),
434                triggered_policy_id: Some("loan-amount-cap".into()),
435                triggered_policy_name: Some("Loan amount cap".into()),
436                trigger_reason: Some("Disbursement above $10k requires manager approval".into()),
437                severity: Some("high".into()),
438                notify_url: Some("https://workflows.example.com/hooks/loan-approve".into()),
439                ..Default::default()
440            })
441            .await
442            .unwrap();
443        assert_eq!(req.request_id, "hitl-req-runtime-001");
444        assert_eq!(
445            req.notify_url.as_deref(),
446            Some("https://workflows.example.com/hooks/loan-approve")
447        );
448    }
449
450    #[tokio::test]
451    async fn create_minimal_required_fields_only() {
452        let server = MockServer::start().await;
453        Mock::given(method("POST"))
454            .and(path("/api/v1/hitl/queue"))
455            .respond_with(ResponseTemplate::new(201).set_body_json(json!({
456                "success": true,
457                "data": {
458                    "request_id": "hitl-req-minimal",
459                    "org_id": "org-1",
460                    "tenant_id": "tenant-1",
461                    "client_id": "c1",
462                    "original_query": "q",
463                    "request_type": "chat",
464                    "triggered_policy_id": "",
465                    "triggered_policy_name": "",
466                    "trigger_reason": "",
467                    "severity": "high",
468                    "status": "pending",
469                    "expires_at": "2026-05-23T11:00:00Z",
470                    "created_at": "2026-05-23T10:00:00Z",
471                    "updated_at": "2026-05-23T10:00:00Z",
472                },
473            })))
474            .mount(&server)
475            .await;
476
477        let client = make_client(server.uri());
478        let req = client
479            .create_hitl_request(HITLCreateInput {
480                client_id: "c1".into(),
481                original_query: "q".into(),
482                request_type: "chat".into(),
483                ..Default::default()
484            })
485            .await
486            .unwrap();
487        assert_eq!(req.request_id, "hitl-req-minimal");
488        assert_eq!(req.notify_url, None);
489    }
490
491    #[tokio::test]
492    async fn create_bad_notify_url_scheme_surfaces_400() {
493        // Mirrors platform/agent/hitl/webhook.go:105 ValidateNotifyURL.
494        let server = MockServer::start().await;
495        Mock::given(method("POST"))
496            .and(path("/api/v1/hitl/queue"))
497            .respond_with(ResponseTemplate::new(400).set_body_json(json!({
498                "success": false,
499                "error": "notify_url scheme \"javascript\" is not allowed (use https:// or http://)",
500            })))
501            .mount(&server)
502            .await;
503
504        let client = make_client(server.uri());
505        let err = client
506            .create_hitl_request(HITLCreateInput {
507                client_id: "loan-desk".into(),
508                original_query: "disburse $50000".into(),
509                request_type: "adk-tool".into(),
510                notify_url: Some("javascript:alert(1)".into()),
511                ..Default::default()
512            })
513            .await
514            .unwrap_err();
515        match err {
516            AxonFlowError::ApiError { status, .. } => assert_eq!(status, 400),
517            other => panic!("expected ApiError(400), got {other}"),
518        }
519    }
520
521    #[tokio::test]
522    async fn create_401_surfaces_as_api_error() {
523        let server = MockServer::start().await;
524        Mock::given(method("POST"))
525            .and(path("/api/v1/hitl/queue"))
526            .respond_with(ResponseTemplate::new(401).set_body_json(json!({
527                "success": false,
528                "error": "Invalid API key",
529            })))
530            .mount(&server)
531            .await;
532
533        let client = make_client(server.uri());
534        let err = client
535            .create_hitl_request(HITLCreateInput {
536                client_id: "loan-desk".into(),
537                original_query: "disburse $50000".into(),
538                request_type: "adk-tool".into(),
539                ..Default::default()
540            })
541            .await
542            .unwrap_err();
543        match err {
544            AxonFlowError::ApiError { status, .. } => assert_eq!(status, 401),
545            other => panic!("expected ApiError(401), got {other}"),
546        }
547    }
548
549    #[tokio::test]
550    async fn create_network_failure_surfaces_as_error() {
551        // Bind+close to get a guaranteed-unused localhost port.
552        let server = MockServer::start().await;
553        let url = server.uri();
554        drop(server);
555
556        let client = make_client(url);
557        let err = client
558            .create_hitl_request(HITLCreateInput {
559                client_id: "loan-desk".into(),
560                original_query: "disburse $50000".into(),
561                request_type: "adk-tool".into(),
562                ..Default::default()
563            })
564            .await
565            .unwrap_err();
566        // Either HttpError or ApiError depending on connect-vs-response race
567        // — either is correct propagation.
568        let _ = err;
569    }
570
571    #[tokio::test]
572    async fn create_missing_client_id_rejected() {
573        let client = make_client("http://127.0.0.1:1".into());
574        let err = client
575            .create_hitl_request(HITLCreateInput {
576                client_id: "".into(),
577                original_query: "q".into(),
578                request_type: "chat".into(),
579                ..Default::default()
580            })
581            .await
582            .unwrap_err();
583        assert!(err.to_string().contains("client_id is required"));
584    }
585
586    #[tokio::test]
587    async fn create_missing_original_query_rejected() {
588        let client = make_client("http://127.0.0.1:1".into());
589        let err = client
590            .create_hitl_request(HITLCreateInput {
591                client_id: "c1".into(),
592                original_query: "".into(),
593                request_type: "chat".into(),
594                ..Default::default()
595            })
596            .await
597            .unwrap_err();
598        assert!(err.to_string().contains("original_query is required"));
599    }
600
601    #[tokio::test]
602    async fn create_missing_request_type_rejected() {
603        let client = make_client("http://127.0.0.1:1".into());
604        let err = client
605            .create_hitl_request(HITLCreateInput {
606                client_id: "c1".into(),
607                original_query: "q".into(),
608                request_type: "".into(),
609                ..Default::default()
610            })
611            .await
612            .unwrap_err();
613        assert!(err.to_string().contains("request_type is required"));
614    }
615
616    // ---- approve / reject ----
617
618    #[tokio::test]
619    async fn approve_posts_review_input_to_correct_path() {
620        let server = MockServer::start().await;
621        Mock::given(method("POST"))
622            .and(path("/api/v1/hitl/queue/hitl-req-runtime-001/approve"))
623            .and(body_partial_json(json!({
624                "reviewer_id": "user_456",
625                "reviewer_email": "reviewer@example.com",
626                "comment": "Approved after review",
627            })))
628            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": true})))
629            .expect(1)
630            .mount(&server)
631            .await;
632
633        let client = make_client(server.uri());
634        client
635            .approve_hitl_request(
636                "hitl-req-runtime-001",
637                HITLReviewInput {
638                    reviewer_id: "user_456".into(),
639                    reviewer_email: "reviewer@example.com".into(),
640                    reviewer_role: None,
641                    comment: Some("Approved after review".into()),
642                },
643            )
644            .await
645            .unwrap();
646    }
647
648    #[tokio::test]
649    async fn reject_posts_review_input_to_correct_path() {
650        let server = MockServer::start().await;
651        Mock::given(method("POST"))
652            .and(path("/api/v1/hitl/queue/hitl-req-runtime-001/reject"))
653            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": true})))
654            .expect(1)
655            .mount(&server)
656            .await;
657
658        let client = make_client(server.uri());
659        client
660            .reject_hitl_request(
661                "hitl-req-runtime-001",
662                HITLReviewInput {
663                    reviewer_id: "user_456".into(),
664                    reviewer_email: "reviewer@example.com".into(),
665                    reviewer_role: None,
666                    comment: None,
667                },
668            )
669            .await
670            .unwrap();
671    }
672
673    #[tokio::test]
674    async fn approve_empty_id_rejected_before_http() {
675        let client = make_client("http://127.0.0.1:1".into());
676        let err = client
677            .approve_hitl_request(
678                "",
679                HITLReviewInput {
680                    reviewer_id: "u".into(),
681                    reviewer_email: "u@e".into(),
682                    reviewer_role: None,
683                    comment: None,
684                },
685            )
686            .await
687            .unwrap_err();
688        assert!(err.to_string().contains("request_id is required"));
689    }
690
691    // ---- stats ----
692
693    #[tokio::test]
694    async fn stats_happy_path_parses_envelope() {
695        let server = MockServer::start().await;
696        Mock::given(method("GET"))
697            .and(path("/api/v1/hitl/stats"))
698            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
699                "success": true,
700                "data": {
701                    "total_pending": 12,
702                    "high_priority": 4,
703                    "critical_priority": 2,
704                    "oldest_pending_hours": 9.5,
705                },
706            })))
707            .mount(&server)
708            .await;
709
710        let client = make_client(server.uri());
711        let stats = client.get_hitl_stats().await.unwrap();
712        assert_eq!(stats.total_pending, 12);
713        assert_eq!(stats.high_priority, 4);
714        assert_eq!(stats.critical_priority, 2);
715        assert_eq!(stats.oldest_pending_hours, Some(9.5));
716    }
717
718    #[test]
719    fn build_list_query_omits_none_fields() {
720        let qs = build_list_query(&HITLQueueListOptions::default());
721        assert_eq!(qs, "");
722        let qs = build_list_query(&HITLQueueListOptions {
723            status: Some("pending".into()),
724            severity: None,
725            limit: Some(20),
726            offset: None,
727        });
728        assert_eq!(qs, "status=pending&limit=20");
729    }
730}