1use std::num::NonZeroU64;
4use std::time::Duration;
5
6use aion_core::{Event, Payload, RunId, WorkflowFilter, WorkflowId, WorkflowSummary};
7use aion_proto::{
8 ProtoCancelRequest, ProtoDescribeWorkflowRequest, ProtoListWorkflowsRequest, ProtoPayload,
9 ProtoQueryRequest, ProtoRunId, ProtoSignalRequest, ProtoStartWorkflowRequest, ProtoWorkflowId,
10 WireError, decode_core_value, decode_event, decode_workflow_summary, encode_core_value,
11 proto_query_response,
12};
13use aion_store::visibility::ListWorkflowsFilter;
14
15use serde::Serialize;
16use serde::de::DeserializeOwned;
17
18use crate::client::Client;
19use crate::error::ClientError;
20use crate::handle::WorkflowHandle;
21use crate::payload::{from_payload, to_payload};
22use crate::stream::{EventStream, SubscribeTarget, event_stream, event_stream_from};
23
24#[derive(Clone, Debug, Default, PartialEq, Eq)]
26pub struct StartOptions {
27 pub namespace: Option<String>,
29 pub idempotency_key: Option<String>,
36 pub routing_key: Option<String>,
40}
41
42#[derive(Clone, Debug, Default, PartialEq, Eq)]
48pub struct ListPage {
49 pub request_id: Option<String>,
51 pub limit: Option<usize>,
53 pub cursor: Option<String>,
55}
56
57#[derive(Clone, Debug, PartialEq)]
59pub struct WorkflowDescription {
60 pub summary: WorkflowSummary,
62 pub history: Vec<Event>,
64}
65
66impl Client {
67 pub async fn start(
73 &self,
74 workflow_type: impl Into<String>,
75 input: Payload,
76 opts: StartOptions,
77 ) -> Result<WorkflowHandle, ClientError> {
78 validate_start_options(&opts)?;
79 let idempotency_key = opts.idempotency_key.clone();
80 let routing_key = opts.routing_key.clone();
81 let namespace = operation_namespace(self, opts.namespace);
82 let workflow_type = workflow_type.into();
83 let fingerprint = idempotency_key.as_ref().map(|key| {
84 StartFingerprint::new(
85 namespace.clone(),
86 workflow_type.clone(),
87 &input,
88 key.clone(),
89 )
90 });
91 if let Some(fingerprint) = &fingerprint {
92 if let Some(handle) = self.cached_start(fingerprint).await? {
93 return Ok(handle);
94 }
95 }
96 let response = self
97 .transport
98 .start_workflow(ProtoStartWorkflowRequest {
99 namespace,
100 workflow_type,
101 input: Some(ProtoPayload::from(input)),
102 routing_key,
103 })
104 .await?;
105 let workflow_id = decode_required_workflow_id(response.workflow_id, "start response")?;
106 let run_id = decode_required_run_id(response.run_id, "start response")?;
107 let handle = WorkflowHandle::from_ids(self.clone(), workflow_id, run_id);
108 if let Some(fingerprint) = fingerprint {
109 self.record_start(fingerprint, handle.clone()).await?;
110 }
111 Ok(handle)
112 }
113
114 pub async fn start_typed<T>(
121 &self,
122 workflow_type: impl Into<String>,
123 input: &T,
124 opts: StartOptions,
125 ) -> Result<WorkflowHandle, ClientError>
126 where
127 T: Serialize + ?Sized,
128 {
129 self.start(workflow_type, to_payload(input)?, opts).await
130 }
131
132 pub async fn signal(
138 &self,
139 workflow_id: &WorkflowId,
140 run_id: Option<&RunId>,
141 name: impl Into<String>,
142 payload: Payload,
143 ) -> Result<(), ClientError> {
144 self.transport
145 .signal(ProtoSignalRequest {
146 namespace: self.namespace().to_owned(),
147 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
148 run_id: run_id.cloned().map(ProtoRunId::from),
149 signal_name: name.into(),
150 payload: Some(ProtoPayload::from(payload)),
151 })
152 .await?;
153 Ok(())
154 }
155
156 pub async fn signal_typed<T>(
163 &self,
164 workflow_id: &WorkflowId,
165 run_id: Option<&RunId>,
166 name: impl Into<String>,
167 value: &T,
168 ) -> Result<(), ClientError>
169 where
170 T: Serialize + ?Sized,
171 {
172 self.signal(workflow_id, run_id, name, to_payload(value)?)
173 .await
174 }
175
176 pub async fn query(
186 &self,
187 workflow_id: &WorkflowId,
188 run_id: Option<&RunId>,
189 name: impl Into<String>,
190 args: Payload,
191 deadline: Duration,
192 ) -> Result<Payload, ClientError> {
193 validate_query_args(&args)?;
194 let response = tokio::time::timeout(
195 deadline,
196 self.transport.query(ProtoQueryRequest {
197 namespace: self.namespace().to_owned(),
198 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
199 run_id: run_id.cloned().map(ProtoRunId::from),
200 query_name: name.into(),
201 }),
202 )
203 .await
204 .map_err(|_| {
205 ClientError::query_timeout(format!(
206 "query deadline of {deadline:?} elapsed before the server replied"
207 ))
208 })??;
209
210 match response.outcome {
211 Some(proto_query_response::Outcome::Result(payload)) => {
212 Payload::try_from(payload).map_err(ClientError::from_wire_error)
213 }
214 Some(proto_query_response::Outcome::Error(error)) => Err(query_error(error)),
215 None => Err(ClientError::server("query response outcome is missing")),
216 }
217 }
218
219 pub async fn query_typed<A, R>(
226 &self,
227 workflow_id: &WorkflowId,
228 run_id: Option<&RunId>,
229 name: impl Into<String>,
230 args: &A,
231 deadline: Duration,
232 ) -> Result<R, ClientError>
233 where
234 A: Serialize + ?Sized,
235 R: DeserializeOwned,
236 {
237 let payload = self
238 .query(
239 workflow_id,
240 run_id,
241 name,
242 query_args_payload(args)?,
243 deadline,
244 )
245 .await?;
246 from_payload(&payload)
247 }
248
249 pub async fn cancel(
258 &self,
259 workflow_id: &WorkflowId,
260 run_id: Option<&RunId>,
261 reason: impl Into<String>,
262 ) -> Result<(), ClientError> {
263 self.transport
264 .cancel(ProtoCancelRequest {
265 namespace: self.namespace().to_owned(),
266 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
267 run_id: run_id.cloned().map(ProtoRunId::from),
268 reason: reason.into(),
269 })
270 .await?;
271 Ok(())
272 }
273
274 pub async fn list(
280 &self,
281 filter: &WorkflowFilter,
282 page: ListPage,
283 ) -> Result<Vec<WorkflowSummary>, ClientError> {
284 validate_list_page(&page)?;
285 let namespace = self.namespace().to_owned();
286 let filter = workflow_filter_to_visibility(filter)?;
287 let filter = encode_core_value(namespace.clone(), page.request_id, &filter)
288 .map_err(ClientError::from_wire_error)?;
289 let response = self
290 .transport
291 .list_workflows(ProtoListWorkflowsRequest {
292 namespace,
293 filter: Some(filter),
294 })
295 .await?;
296
297 response
298 .summaries
299 .iter()
300 .map(decode_visibility_summary)
301 .map(|result| result.map_err(ClientError::from_wire_error))
302 .collect()
303 }
304
305 pub async fn describe(
311 &self,
312 workflow_id: &WorkflowId,
313 run_id: Option<&RunId>,
314 ) -> Result<WorkflowDescription, ClientError> {
315 let response = self
316 .transport
317 .describe_workflow(ProtoDescribeWorkflowRequest {
318 namespace: self.namespace().to_owned(),
319 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
320 run_id: run_id.cloned().map(ProtoRunId::from),
321 include_history: true,
322 })
323 .await?;
324 let summary = response
325 .summary
326 .as_ref()
327 .ok_or_else(|| ClientError::server("describe response summary is missing"))
328 .and_then(|summary| {
329 decode_workflow_summary(summary).map_err(ClientError::from_wire_error)
330 })?;
331 let history = response
332 .history
333 .iter()
334 .map(decode_event)
335 .map(|result| result.map_err(ClientError::from_wire_error))
336 .collect::<Result<Vec<_>, _>>()?;
337 Ok(WorkflowDescription { summary, history })
338 }
339
340 #[must_use]
342 pub fn subscribe_workflow(&self, workflow_id: &WorkflowId) -> EventStream {
343 event_stream(
344 self.transport.clone(),
345 self.namespace().to_owned(),
346 SubscribeTarget::Workflow {
347 workflow_id: workflow_id.clone(),
348 },
349 )
350 }
351
352 #[must_use]
359 pub fn subscribe_workflow_from(
360 &self,
361 workflow_id: &WorkflowId,
362 resume_from: NonZeroU64,
363 ) -> EventStream {
364 event_stream_from(
365 self.transport.clone(),
366 self.namespace().to_owned(),
367 workflow_id.clone(),
368 resume_from,
369 )
370 }
371
372 #[must_use]
374 pub fn subscribe(&self, filter: WorkflowFilter) -> EventStream {
375 event_stream(
376 self.transport.clone(),
377 self.namespace().to_owned(),
378 SubscribeTarget::Filtered { filter },
379 )
380 }
381
382 #[must_use]
384 pub fn subscribe_firehose(&self) -> EventStream {
385 event_stream(
386 self.transport.clone(),
387 self.namespace().to_owned(),
388 SubscribeTarget::Firehose,
389 )
390 }
391}
392
393#[derive(Clone, Debug, PartialEq, Eq)]
394pub(crate) struct StartFingerprint {
395 namespace: String,
396 workflow_type: String,
397 content_type: aion_core::ContentType,
398 bytes: Vec<u8>,
399 idempotency_key: String,
400}
401
402impl StartFingerprint {
403 fn new(
404 namespace: String,
405 workflow_type: String,
406 input: &Payload,
407 idempotency_key: String,
408 ) -> Self {
409 Self {
410 namespace,
411 workflow_type,
412 content_type: input.content_type().clone(),
413 bytes: input.bytes().to_vec(),
414 idempotency_key,
415 }
416 }
417
418 pub(crate) fn key(&self) -> &str {
419 &self.idempotency_key
420 }
421}
422
423fn operation_namespace(client: &Client, namespace: Option<String>) -> String {
424 namespace.unwrap_or_else(|| client.namespace().to_owned())
425}
426
427fn validate_start_options(opts: &StartOptions) -> Result<(), ClientError> {
428 if opts
429 .idempotency_key
430 .as_ref()
431 .is_some_and(std::string::String::is_empty)
432 {
433 return Err(ClientError::invalid_argument(
434 "idempotency_key must not be empty",
435 ));
436 }
437 Ok(())
438}
439
440fn validate_query_args(args: &Payload) -> Result<(), ClientError> {
441 if !args.bytes().is_empty() {
442 return Err(ClientError::invalid_argument(
443 "query arguments are not carried by the current wire contract; \
444 pass an empty payload",
445 ));
446 }
447 Ok(())
448}
449
450fn query_args_payload<T>(args: &T) -> Result<Payload, ClientError>
451where
452 T: Serialize + ?Sized,
453{
454 let payload = to_payload(args)?;
455 if payload.bytes() == b"null" {
456 Ok(Payload::new(payload.content_type().clone(), Vec::new()))
457 } else {
458 Ok(payload)
459 }
460}
461
462fn validate_list_page(page: &ListPage) -> Result<(), ClientError> {
463 if page.limit.is_some() || page.cursor.is_some() {
464 return Err(ClientError::invalid_argument(
465 "list pagination limit/cursor are reserved by the contract and \
466 not yet carried by the wire",
467 ));
468 }
469 Ok(())
470}
471
472fn workflow_filter_to_visibility(
473 filter: &WorkflowFilter,
474) -> Result<ListWorkflowsFilter, ClientError> {
475 if filter.parent.is_some() {
476 return Err(ClientError::invalid_argument(
477 "parent workflow filters are not carried by the visibility wire contract",
478 ));
479 }
480
481 Ok(ListWorkflowsFilter {
482 workflow_type: filter.workflow_type.clone(),
483 status: filter.status,
484 started_after: filter.started_after,
485 started_before: filter.started_before,
486 ..ListWorkflowsFilter::default()
487 })
488}
489
490fn decode_visibility_summary(
491 envelope: &aion_proto::WireEnvelope,
492) -> Result<WorkflowSummary, WireError> {
493 let summary = decode_core_value::<aion_store::visibility::WorkflowSummary>(envelope)?;
494 Ok(WorkflowSummary {
495 workflow_id: summary.workflow_id,
496 workflow_type: summary.workflow_type,
497 status: summary.status,
498 started_at: summary.start_time,
499 ended_at: summary.close_time,
500 parent: None,
501 })
502}
503
504fn decode_required_workflow_id(
505 value: Option<ProtoWorkflowId>,
506 context: &str,
507) -> Result<WorkflowId, ClientError> {
508 value
509 .ok_or_else(|| ClientError::server(format!("{context} workflow id is missing")))?
510 .try_into()
511 .map_err(ClientError::from_wire_error)
512}
513
514fn decode_required_run_id(value: Option<ProtoRunId>, context: &str) -> Result<RunId, ClientError> {
515 value
516 .ok_or_else(|| ClientError::server(format!("{context} run id is missing")))?
517 .try_into()
518 .map_err(ClientError::from_wire_error)
519}
520
521fn query_error(error: aion_proto::ProtoWireError) -> ClientError {
527 ClientError::from_proto_wire_error(error)
528}
529
530#[cfg(test)]
531mod tests {
532 use std::sync::Arc;
533 use std::time::Duration;
534
535 use aion_core::{ContentType, Payload, WorkflowFilter, WorkflowId, WorkflowStatus};
536 use aion_proto::{
537 ProtoCancelResponse, ProtoDescribeWorkflowResponse, ProtoListWorkflowsResponse,
538 ProtoQueryResponse, ProtoRunId, ProtoSignalResponse, ProtoStartWorkflowResponse,
539 ProtoWorkflowId, WireError, encode_core_value, encode_workflow_summary,
540 proto_query_response,
541 };
542 use async_trait::async_trait;
543 use chrono::Utc;
544 use futures::StreamExt;
545 use futures::stream;
546 use tokio::sync::Mutex;
547
548 use super::{ListPage, StartOptions};
549 use crate::client::{Client, ClientBuilder, ClientConfig};
550 use crate::error::ClientError;
551 use crate::transport::{SubscriptionAttempt, WorkflowTransport};
552
553 #[derive(Default)]
554 struct StubTransport {
555 last_start: Mutex<Option<aion_proto::ProtoStartWorkflowRequest>>,
556 last_signal: Mutex<Option<aion_proto::ProtoSignalRequest>>,
557 last_query: Mutex<Option<aion_proto::ProtoQueryRequest>>,
558 last_cancel: Mutex<Option<aion_proto::ProtoCancelRequest>>,
559 last_list: Mutex<Option<aion_proto::ProtoListWorkflowsRequest>>,
560 last_describe: Mutex<Option<aion_proto::ProtoDescribeWorkflowRequest>>,
561 start_error: Mutex<Option<ClientError>>,
562 signal_error: Mutex<Option<ClientError>>,
563 query_response: Mutex<Option<Result<ProtoQueryResponse, ClientError>>>,
564 }
565
566 #[async_trait]
567 impl WorkflowTransport for StubTransport {
568 async fn start_workflow(
569 &self,
570 request: aion_proto::ProtoStartWorkflowRequest,
571 ) -> Result<ProtoStartWorkflowResponse, ClientError> {
572 *self.last_start.lock().await = Some(request);
573 if let Some(error) = self.start_error.lock().await.take() {
574 return Err(error);
575 }
576 Ok(ProtoStartWorkflowResponse {
577 workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
578 run_id: Some(ProtoRunId::from(run_id())),
579 })
580 }
581
582 async fn signal(
583 &self,
584 request: aion_proto::ProtoSignalRequest,
585 ) -> Result<ProtoSignalResponse, ClientError> {
586 *self.last_signal.lock().await = Some(request);
587 if let Some(error) = self.signal_error.lock().await.take() {
588 return Err(error);
589 }
590 Ok(ProtoSignalResponse {})
591 }
592
593 async fn query(
594 &self,
595 request: aion_proto::ProtoQueryRequest,
596 ) -> Result<ProtoQueryResponse, ClientError> {
597 *self.last_query.lock().await = Some(request);
598 if let Some(response) = self.query_response.lock().await.take() {
599 return response;
600 }
601 Ok(ProtoQueryResponse {
602 outcome: Some(proto_query_response::Outcome::Result(
603 aion_proto::ProtoPayload::from(payload("result")),
604 )),
605 })
606 }
607
608 async fn cancel(
609 &self,
610 request: aion_proto::ProtoCancelRequest,
611 ) -> Result<ProtoCancelResponse, ClientError> {
612 *self.last_cancel.lock().await = Some(request);
613 Ok(ProtoCancelResponse {})
614 }
615
616 async fn list_workflows(
617 &self,
618 request: aion_proto::ProtoListWorkflowsRequest,
619 ) -> Result<ProtoListWorkflowsResponse, ClientError> {
620 *self.last_list.lock().await = Some(request);
621 Ok(ProtoListWorkflowsResponse {
622 summaries: vec![
623 encode_core_value("tenant-a", None, &visibility_summary())
624 .map_err(ClientError::from_wire_error)?,
625 ],
626 })
627 }
628
629 async fn describe_workflow(
630 &self,
631 request: aion_proto::ProtoDescribeWorkflowRequest,
632 ) -> Result<ProtoDescribeWorkflowResponse, ClientError> {
633 *self.last_describe.lock().await = Some(request);
634 Ok(ProtoDescribeWorkflowResponse {
635 summary: Some(
636 encode_workflow_summary("tenant-a", None, &summary())
637 .map_err(ClientError::from_wire_error)?,
638 ),
639 history: Vec::new(),
640 })
641 }
642
643 async fn subscribe(
644 &self,
645 _: aion_proto::SubscriptionRequest,
646 _: Option<u64>,
647 ) -> Result<SubscriptionAttempt, ClientError> {
648 Ok(SubscriptionAttempt::new(stream::empty().boxed()))
649 }
650 }
651
652 fn client_with(stub: Arc<StubTransport>) -> Client {
653 Client::from_transport(
654 ClientConfig::from(
655 ClientBuilder::new("http://localhost:50051").with_namespace("tenant-a"),
656 ),
657 stub,
658 )
659 }
660
661 fn workflow_id() -> WorkflowId {
662 WorkflowId::new_v4()
663 }
664
665 fn run_id() -> aion_core::RunId {
666 aion_core::RunId::new_v4()
667 }
668
669 fn payload(label: &str) -> Payload {
670 Payload::new(
671 ContentType::Json,
672 format!("{{\"label\":\"{label}\"}}").into_bytes(),
673 )
674 }
675
676 fn empty_payload() -> Payload {
677 Payload::new(ContentType::Json, Vec::new())
678 }
679
680 fn summary() -> aion_core::WorkflowSummary {
681 aion_core::WorkflowSummary {
682 workflow_id: workflow_id(),
683 workflow_type: String::from("checkout"),
684 status: WorkflowStatus::Running,
685 started_at: Utc::now(),
686 ended_at: None,
687 parent: None,
688 }
689 }
690
691 fn visibility_summary() -> aion_store::visibility::WorkflowSummary {
692 aion_store::visibility::WorkflowSummary {
693 workflow_id: workflow_id(),
694 run_id: run_id(),
695 workflow_type: String::from("checkout"),
696 status: WorkflowStatus::Running,
697 start_time: Utc::now(),
698 close_time: None,
699 search_attributes: std::collections::HashMap::new(),
700 }
701 }
702
703 #[tokio::test]
704 async fn start_maps_request_and_returns_handle() -> Result<(), ClientError> {
705 let stub = Arc::new(StubTransport::default());
706 let client = client_with(Arc::clone(&stub));
707
708 let result = client
709 .start("checkout", payload("input"), StartOptions::default())
710 .await?;
711
712 let recorded = stub.last_start.lock().await.clone();
713 assert!(recorded.is_some());
714 let request = recorded.ok_or_else(|| ClientError::server("missing recorded start"))?;
715 assert_eq!(request.namespace, "tenant-a");
716 assert_eq!(request.workflow_type, "checkout");
717 assert!(request.input.is_some());
718 assert_ne!(result.workflow_id(), &WorkflowId::new(uuid::Uuid::nil()));
719 Ok(())
720 }
721
722 #[tokio::test]
723 async fn start_idempotency_replays_identical_and_rejects_conflicts() -> Result<(), ClientError>
724 {
725 let stub = Arc::new(StubTransport::default());
726 let client = client_with(Arc::clone(&stub));
727 let opts = StartOptions {
728 namespace: None,
729 idempotency_key: Some(String::from("retry-key")),
730 routing_key: None,
731 };
732
733 let original = client
734 .start("checkout", payload("input"), opts.clone())
735 .await?;
736 let replayed = client
737 .start("checkout", payload("input"), opts.clone())
738 .await?;
739 let conflict = client.start("checkout", payload("other"), opts).await;
740
741 assert_eq!(replayed, original);
742 assert!(
743 matches!(conflict, Err(ClientError::AlreadyExists { .. })),
744 "got {conflict:?}"
745 );
746 Ok(())
747 }
748
749 #[tokio::test]
750 async fn signal_maps_latest_run_and_error() {
751 let stub = Arc::new(StubTransport::default());
752 *stub.signal_error.lock().await = Some(ClientError::not_found("workflow was not found"));
753 let client = client_with(Arc::clone(&stub));
754 let id = workflow_id();
755
756 let result = client.signal(&id, None, "approve", payload("signal")).await;
757
758 assert_eq!(
759 result,
760 Err(ClientError::not_found("workflow was not found"))
761 );
762 let recorded = stub.last_signal.lock().await.clone();
763 assert!(recorded.is_some());
764 let Some(request) = recorded else {
765 return;
766 };
767 assert!(request.run_id.is_none());
768 }
769
770 #[tokio::test]
771 async fn query_maps_result_error_and_deadline() -> Result<(), ClientError> {
772 let stub = Arc::new(StubTransport::default());
773 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
774 outcome: Some(proto_query_response::Outcome::Error(
775 aion_proto::ProtoWireError::from(WireError::query_timeout("slow")),
776 )),
777 }));
778 let client = client_with(Arc::clone(&stub));
779 let id = workflow_id();
780
781 let result = client
782 .query(
783 &id,
784 Some(&run_id()),
785 "state",
786 empty_payload(),
787 Duration::from_secs(1),
788 )
789 .await;
790 let unsupported_args = client
791 .query(&id, None, "state", payload("args"), Duration::from_secs(1))
792 .await;
793
794 assert_eq!(result, Err(ClientError::query_timeout("slow")));
795 assert!(
796 matches!(unsupported_args, Err(ClientError::InvalidArgument { .. })),
797 "got {unsupported_args:?}"
798 );
799 let recorded = stub.last_query.lock().await.clone();
800 assert!(recorded.is_some());
801 let request = recorded.ok_or_else(|| ClientError::server("missing query"))?;
802 assert!(request.run_id.is_some());
803 Ok(())
804 }
805
806 #[tokio::test]
807 async fn query_failed_outcome_error_maps_to_query_failed() -> Result<(), ClientError> {
808 let stub = Arc::new(StubTransport::default());
809 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
810 outcome: Some(proto_query_response::Outcome::Error(
811 aion_proto::ProtoWireError::from(WireError::query_failed("handler raised")),
812 )),
813 }));
814 let client = client_with(Arc::clone(&stub));
815
816 let result = client
817 .query(
818 &workflow_id(),
819 Some(&run_id()),
820 "state",
821 empty_payload(),
822 Duration::from_secs(1),
823 )
824 .await;
825
826 assert_eq!(result, Err(ClientError::query_failed("handler raised")));
827 Ok(())
828 }
829
830 #[tokio::test]
831 async fn backend_outcome_error_is_a_server_fault_not_query_failed() -> Result<(), ClientError> {
832 let stub = Arc::new(StubTransport::default());
835 *stub.query_response.lock().await = Some(Ok(ProtoQueryResponse {
836 outcome: Some(proto_query_response::Outcome::Error(
837 aion_proto::ProtoWireError::from(WireError::backend("store down")),
838 )),
839 }));
840 let client = client_with(Arc::clone(&stub));
841
842 let result = client
843 .query(
844 &workflow_id(),
845 Some(&run_id()),
846 "state",
847 empty_payload(),
848 Duration::from_secs(1),
849 )
850 .await;
851
852 assert_eq!(result, Err(ClientError::server("store down")));
853 Ok(())
854 }
855
856 #[tokio::test]
857 async fn query_typed_decodes_no_arg_query_result() -> Result<(), ClientError> {
858 #[derive(serde::Deserialize, PartialEq, Eq, Debug)]
859 struct QueryResult {
860 label: String,
861 }
862
863 let stub = Arc::new(StubTransport::default());
864 let client = client_with(Arc::clone(&stub));
865 let id = workflow_id();
866
867 let result: QueryResult = client
868 .query_typed(&id, Some(&run_id()), "state", &(), Duration::from_secs(1))
869 .await?;
870
871 assert_eq!(
872 result,
873 QueryResult {
874 label: String::from("result")
875 }
876 );
877 assert!(stub.last_query.lock().await.is_some());
878 Ok(())
879 }
880
881 #[tokio::test]
882 async fn query_typed_rejects_non_empty_args_without_silent_drop() {
883 let stub = Arc::new(StubTransport::default());
884 let client = client_with(Arc::clone(&stub));
885 let id = workflow_id();
886
887 let result = client
888 .query_typed::<_, serde_json::Value>(
889 &id,
890 Some(&run_id()),
891 "state",
892 &serde_json::json!({ "filter": "open" }),
893 Duration::from_secs(1),
894 )
895 .await;
896
897 assert!(
898 matches!(result, Err(ClientError::InvalidArgument { .. })),
899 "got {result:?}"
900 );
901 assert!(stub.last_query.lock().await.is_none());
902 }
903
904 #[tokio::test]
905 async fn cancel_list_and_describe_map_requests() -> Result<(), ClientError> {
906 let stub = Arc::new(StubTransport::default());
907 let client = client_with(Arc::clone(&stub));
908 let id = workflow_id();
909 let run = run_id();
910
911 client.cancel(&id, Some(&run), "not needed").await?;
912 let listed = client
913 .list(&WorkflowFilter::default(), ListPage::default())
914 .await?;
915 let described = client.describe(&id, None).await?;
916
917 assert!(stub.last_cancel.lock().await.is_some());
918 assert!(stub.last_list.lock().await.is_some());
919 let describe = stub
920 .last_describe
921 .lock()
922 .await
923 .clone()
924 .ok_or_else(|| ClientError::server("missing describe"))?;
925 assert!(describe.run_id.is_none());
926 assert!(describe.include_history);
927 assert_eq!(listed.len(), 1);
928 assert_eq!(described.history.len(), 0);
929 Ok(())
930 }
931}