#![allow(clippy::unwrap_used)]
#![allow(clippy::expect_used)]
#![allow(clippy::clone_on_ref_ptr)]
use duroxide::providers::Provider;
use duroxide::providers::sqlite::SqliteProvider;
use duroxide::runtime;
use duroxide::runtime::registry::ActivityRegistry;
use duroxide::{Client, OrchestrationContext, OrchestrationRegistry, OrchestrationStatus};
use std::sync::Arc;
use std::time::Duration;
async fn client() -> Client {
let store: Arc<dyn Provider> = Arc::new(SqliteProvider::new_in_memory().await.unwrap());
Client::new(store)
}
#[tokio::test]
async fn start_orchestration_rejects_reserved_infix() {
let err = client()
.await
.start_orchestration("victim::sub::2", "AnyOrch", "")
.await
.expect_err("instance id containing the reserved '::sub::' marker must be rejected");
assert!(
matches!(err, duroxide::ClientError::InvalidInput { .. }),
"expected InvalidInput, got {err:?}"
);
}
#[tokio::test]
async fn start_orchestration_rejects_reserved_prefix() {
let err = client()
.await
.start_orchestration("sub::pending_1", "AnyOrch", "")
.await
.expect_err("instance id starting with the reserved 'sub::' marker must be rejected");
assert!(matches!(err, duroxide::ClientError::InvalidInput { .. }), "got {err:?}");
}
#[tokio::test]
async fn start_orchestration_versioned_rejects_reserved_infix() {
let err = client()
.await
.start_orchestration_versioned("a::sub::3", "AnyOrch", "1.0.0", "")
.await
.expect_err("instance id containing the reserved '::sub::' marker must be rejected");
assert!(matches!(err, duroxide::ClientError::InvalidInput { .. }), "got {err:?}");
}
#[tokio::test]
async fn start_orchestration_accepts_normal_id() {
client()
.await
.start_orchestration("order-123", "AnyOrch", "")
.await
.expect("normal instance id must be accepted");
}
#[tokio::test]
async fn start_orchestration_accepts_non_reserved_double_colon() {
client()
.await
.start_orchestration("i4::child-1", "AnyOrch", "")
.await
.expect("non-reserved '::' instance id must be accepted");
}
async fn run_parent_with_detached_id(detached_id: &'static str) -> (OrchestrationStatus, Vec<String>) {
let store: Arc<dyn Provider> = Arc::new(SqliteProvider::new_in_memory().await.unwrap());
let parent = move |ctx: OrchestrationContext, _input: String| async move {
ctx.schedule_orchestration("Detached", detached_id, "payload");
Ok("scheduled".to_string())
};
let detached = |_ctx: OrchestrationContext, _input: String| async move { Ok("detached-done".to_string()) };
let orchs = OrchestrationRegistry::builder()
.register("Parent", parent)
.register("Detached", detached)
.build();
let rt = runtime::Runtime::start_with_store(store.clone(), ActivityRegistry::builder().build(), orchs).await;
let client = Client::new(store.clone());
client.start_orchestration("dp", "Parent", "").await.unwrap();
let status = client
.wait_for_orchestration("dp", Duration::from_secs(10))
.await
.expect("parent must reach a terminal state");
tokio::time::sleep(Duration::from_millis(300)).await;
let instances = store
.as_management_capability()
.expect("management capability")
.list_instances()
.await
.unwrap();
rt.shutdown(None).await;
(status, instances)
}
#[tokio::test]
async fn schedule_orchestration_rejects_reserved_prefix() {
let (status, instances) = run_parent_with_detached_id("sub::squatter").await;
assert!(
matches!(&status, OrchestrationStatus::Failed { details, .. }
if details.display_message().contains("reserved sub-orchestration marker")),
"parent should fail with the reserved-marker error; got {status:?}"
);
assert_eq!(
instances,
vec!["dp".to_string()],
"no detached instance may be created for a rejected id"
);
}
#[tokio::test]
async fn schedule_orchestration_rejects_reserved_infix() {
let (status, instances) = run_parent_with_detached_id("victim::sub::2").await;
assert!(
matches!(&status, OrchestrationStatus::Failed { details, .. }
if details.display_message().contains("reserved sub-orchestration marker")),
"parent should fail with the reserved-marker error; got {status:?}"
);
assert_eq!(
instances,
vec!["dp".to_string()],
"no detached instance may be created for a rejected id"
);
}
#[tokio::test]
async fn schedule_orchestration_accepts_normal_id() {
let (status, mut instances) = run_parent_with_detached_id("tenant-7::order-42").await;
assert!(
matches!(&status, OrchestrationStatus::Completed { output, .. } if output == "scheduled"),
"parent should complete; got {status:?}"
);
instances.sort();
assert_eq!(
instances,
vec!["dp".to_string(), "tenant-7::order-42".to_string()],
"detached instance must be created with the id used verbatim"
);
}
async fn run_parent_with_explicit_child_id(explicit_id: &'static str) -> (OrchestrationStatus, Vec<String>) {
let store: Arc<dyn Provider> = Arc::new(SqliteProvider::new_in_memory().await.unwrap());
let parent = move |ctx: OrchestrationContext, _input: String| async move {
match ctx
.schedule_sub_orchestration_with_id("Child", explicit_id, "child-input")
.await
{
Ok(r) => Ok(format!("ok:{r}")),
Err(e) => Err(format!("rejected:{e}")),
}
};
let child = |_ctx: OrchestrationContext, input: String| async move { Ok(format!("child-done:{input}")) };
let orchs = OrchestrationRegistry::builder()
.register("Parent", parent)
.register("Child", child)
.build();
let rt = runtime::Runtime::start_with_store(store.clone(), ActivityRegistry::builder().build(), orchs).await;
let client = Client::new(store.clone());
client.start_orchestration("p", "Parent", "").await.unwrap();
let status = client
.wait_for_orchestration("p", Duration::from_secs(10))
.await
.expect("parent must reach a terminal state");
let instances = store
.as_management_capability()
.expect("management capability")
.list_instances()
.await
.unwrap();
rt.shutdown(None).await;
(status, instances)
}
#[tokio::test]
async fn sub_orchestration_with_id_rejects_leading_reserved_marker() {
let (status, instances) = run_parent_with_explicit_child_id("sub::my-child").await;
match &status {
OrchestrationStatus::Failed { details, .. } => {
let msg = details.display_message();
assert!(
msg.contains("reserved marker") && msg.contains("sub::my-child"),
"error should name the reserved marker and the offending id; got {msg:?}"
);
}
other => panic!("parent should fail with the rejection error; got {other:?}"),
}
assert_eq!(
instances,
vec!["p".to_string()],
"no child instance may be created for a rejected id (in particular not the \
silently-prefixed 'p::sub::my-child')"
);
}
#[tokio::test]
async fn sub_orchestration_with_id_rejects_pending_placeholder_prefix() {
let (status, instances) = run_parent_with_explicit_child_id("sub::pending_99").await;
assert!(
matches!(&status, OrchestrationStatus::Failed { details, .. }
if details.display_message().contains("reserved marker")),
"parent should fail with the rejection error; got {status:?}"
);
assert_eq!(
instances,
vec!["p".to_string()],
"the placeholder-shaped id must not be silently replaced by an auto-generated child"
);
}
#[tokio::test]
async fn sub_orchestration_with_id_allows_reserved_marker_infix() {
let (status, mut instances) = run_parent_with_explicit_child_id("tenant::sub::99").await;
assert!(
matches!(&status, OrchestrationStatus::Completed { output, .. } if output == "ok:child-done:child-input"),
"an infix marker must be accepted; got {status:?}"
);
instances.sort();
assert_eq!(
instances,
vec!["p".to_string(), "tenant::sub::99".to_string()],
"the explicit id must be used verbatim, with no parent prefix added"
);
}
#[tokio::test]
async fn sub_orchestration_versioned_with_id_rejects_leading_reserved_marker() {
let store: Arc<dyn Provider> = Arc::new(SqliteProvider::new_in_memory().await.unwrap());
let parent = |ctx: OrchestrationContext, _input: String| async move {
match ctx
.schedule_sub_orchestration_versioned_with_id("Child", Some("1.0.0".to_string()), "sub::v", "x")
.await
{
Ok(r) => Ok(format!("ok:{r}")),
Err(e) => Err(format!("rejected:{e}")),
}
};
let child = |_ctx: OrchestrationContext, _input: String| async move { Ok("child-done".to_string()) };
let orchs = OrchestrationRegistry::builder()
.register("Parent", parent)
.register("Child", child)
.build();
let rt = runtime::Runtime::start_with_store(store.clone(), ActivityRegistry::builder().build(), orchs).await;
let client = Client::new(store.clone());
client.start_orchestration("pv", "Parent", "").await.unwrap();
let status = client
.wait_for_orchestration("pv", Duration::from_secs(10))
.await
.expect("parent must reach a terminal state");
assert!(
matches!(&status, OrchestrationStatus::Failed { details, .. }
if details.display_message().contains("reserved marker")),
"versioned overload should reject the same ids; got {status:?}"
);
rt.shutdown(None).await;
}
#[tokio::test]
async fn rejected_explicit_id_writes_no_scheduling_event() {
let store: Arc<dyn Provider> = Arc::new(SqliteProvider::new_in_memory().await.unwrap());
let parent = |ctx: OrchestrationContext, _input: String| async move {
let outcome = match ctx.schedule_sub_orchestration_with_id("Child", "sub::nope", "x").await {
Ok(_) => "scheduled",
Err(_) => "rejected",
};
Ok(outcome.to_string())
};
let child = |_ctx: OrchestrationContext, _input: String| async move { Ok("child-done".to_string()) };
let orchs = OrchestrationRegistry::builder()
.register("Parent", parent)
.register("Child", child)
.build();
let rt = runtime::Runtime::start_with_store(store.clone(), ActivityRegistry::builder().build(), orchs).await;
let client = Client::new(store.clone());
client.start_orchestration("ph", "Parent", "").await.unwrap();
let status = client
.wait_for_orchestration("ph", Duration::from_secs(10))
.await
.expect("parent must complete");
assert!(
matches!(&status, OrchestrationStatus::Completed { output, .. } if output == "rejected"),
"the future should resolve to Err without scheduling; got {status:?}"
);
let history = store.read("ph").await.unwrap();
assert!(
!history
.iter()
.any(|e| matches!(e.kind, duroxide::EventKind::SubOrchestrationScheduled { .. })),
"a rejected id must not produce a SubOrchestrationScheduled event; got {history:#?}"
);
rt.shutdown(None).await;
}