1use 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 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 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 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 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 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 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 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
229fn 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 #[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 #[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 #[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 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 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 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 #[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 #[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}