|
| 1 | +//! # `cano::testing` — batteries-included test fixtures |
| 2 | +//! |
| 3 | +//! Run with: `cargo run --example testing_helpers --features testing` |
| 4 | +//! |
| 5 | +//! Everything here lives behind the `testing` feature and is imported wholesale with |
| 6 | +//! `use cano::testing::*;`. This example exercises each helper the way a test would: |
| 7 | +//! |
| 8 | +//! - [`RecordingObserver`] — capture and assert the path a workflow took. |
| 9 | +//! - [`InMemoryCheckpointStore`] — a process-local checkpoint store (no `recovery` |
| 10 | +//! feature, no on-disk file) whose `Checkpoint` events the observer records. |
| 11 | +//! - [`TestResources`] — build the [`Resources`] a workflow needs in one chain. |
| 12 | +//! - [`panic_on_attempt`] — a task that panics; the engine converts it to an error |
| 13 | +//! (panic safety) and fails fast. |
| 14 | +//! - [`assert_compensation_ran`] — assert the order a saga rolled back in. |
| 15 | +
|
| 16 | +use std::sync::{Arc, Mutex}; |
| 17 | + |
| 18 | +use cano::prelude::*; |
| 19 | +use cano::testing::*; |
| 20 | + |
| 21 | +#[derive(Debug, Clone, PartialEq, Eq, Hash)] |
| 22 | +enum Step { |
| 23 | + Start, |
| 24 | + Work, |
| 25 | + Finish, |
| 26 | + Done, |
| 27 | +} |
| 28 | + |
| 29 | +struct StartTask; |
| 30 | +#[task(state = Step)] |
| 31 | +impl StartTask { |
| 32 | + async fn run_bare(&self) -> Result<TaskResult<Step>, CanoError> { |
| 33 | + Ok(TaskResult::Single(Step::Work)) |
| 34 | + } |
| 35 | +} |
| 36 | + |
| 37 | +struct WorkTask; |
| 38 | +#[task(state = Step)] |
| 39 | +impl WorkTask { |
| 40 | + async fn run(&self, res: &Resources) -> Result<TaskResult<Step>, CanoError> { |
| 41 | + let store = res.get::<MemoryStore, _>("store")?; |
| 42 | + store.put("processed", 42_u32)?; |
| 43 | + Ok(TaskResult::Single(Step::Done)) |
| 44 | + } |
| 45 | +} |
| 46 | + |
| 47 | +// ---- A tiny saga whose compensations record the order they undo in. ------------------- |
| 48 | + |
| 49 | +/// Shared, ordered log of which compensations ran. Registered as a resource so every |
| 50 | +/// step can reach it; `Clone` shares the inner `Arc`, so a handle kept outside the |
| 51 | +/// workflow reads the same `Vec`. |
| 52 | +#[derive(Default, Clone)] |
| 53 | +struct CompensationLog(Arc<Mutex<Vec<String>>>); |
| 54 | +#[resource] |
| 55 | +impl Resource for CompensationLog {} |
| 56 | + |
| 57 | +#[derive(Debug, serde::Serialize, serde::Deserialize)] |
| 58 | +struct Unit; |
| 59 | + |
| 60 | +struct Reserve; |
| 61 | +#[saga::task(state = Step)] |
| 62 | +impl Reserve { |
| 63 | + type Output = Unit; |
| 64 | + async fn run(&self, _res: &Resources) -> Result<(TaskResult<Step>, Unit), CanoError> { |
| 65 | + Ok((TaskResult::Single(Step::Work), Unit)) |
| 66 | + } |
| 67 | + async fn compensate(&self, res: &Resources, _out: Unit) -> Result<(), CanoError> { |
| 68 | + res.get::<CompensationLog, _>("log")? |
| 69 | + .0 |
| 70 | + .lock() |
| 71 | + .unwrap() |
| 72 | + .push("reserve".into()); |
| 73 | + Ok(()) |
| 74 | + } |
| 75 | +} |
| 76 | + |
| 77 | +struct Charge; |
| 78 | +#[saga::task(state = Step)] |
| 79 | +impl Charge { |
| 80 | + type Output = Unit; |
| 81 | + async fn run(&self, _res: &Resources) -> Result<(TaskResult<Step>, Unit), CanoError> { |
| 82 | + Ok((TaskResult::Single(Step::Finish), Unit)) |
| 83 | + } |
| 84 | + async fn compensate(&self, res: &Resources, _out: Unit) -> Result<(), CanoError> { |
| 85 | + res.get::<CompensationLog, _>("log")? |
| 86 | + .0 |
| 87 | + .lock() |
| 88 | + .unwrap() |
| 89 | + .push("charge".into()); |
| 90 | + Ok(()) |
| 91 | + } |
| 92 | +} |
| 93 | + |
| 94 | +struct Boom; |
| 95 | +#[task(state = Step)] |
| 96 | +impl Boom { |
| 97 | + fn config(&self) -> TaskConfig { |
| 98 | + TaskConfig::minimal() |
| 99 | + } |
| 100 | + async fn run_bare(&self) -> Result<TaskResult<Step>, CanoError> { |
| 101 | + Err(CanoError::task_execution("boom")) |
| 102 | + } |
| 103 | +} |
| 104 | + |
| 105 | +#[tokio::main] |
| 106 | +async fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 107 | + // 1. RecordingObserver + InMemoryCheckpointStore + TestResources -------------------- |
| 108 | + let observer = Arc::new(RecordingObserver::new()); |
| 109 | + let checkpoints = Arc::new(InMemoryCheckpointStore::new()); |
| 110 | + let resources = TestResources::new().with_store("store").build(); |
| 111 | + |
| 112 | + let workflow = Workflow::new(resources) |
| 113 | + .register(Step::Start, StartTask) |
| 114 | + .register(Step::Work, WorkTask) |
| 115 | + .add_exit_state(Step::Done) |
| 116 | + .with_observer(observer.clone()) |
| 117 | + .with_checkpoint_store(checkpoints.clone()) |
| 118 | + .with_workflow_id("demo-run"); |
| 119 | + |
| 120 | + let final_state = workflow.orchestrate(Step::Start).await?; |
| 121 | + assert_eq!(final_state, Step::Done); |
| 122 | + |
| 123 | + // The observer captured the whole path and the checkpoint appends along the way. |
| 124 | + observer.assert_path(&["Start", "Work", "Done"]); |
| 125 | + observer.assert_completed_with("Done"); |
| 126 | + let checkpoint_events = observer |
| 127 | + .events() |
| 128 | + .into_iter() |
| 129 | + .filter(|e| matches!(e, RecordedEvent::Checkpoint { .. })) |
| 130 | + .count(); |
| 131 | + println!("observer recorded path {:?}", observer.states_entered()); |
| 132 | + println!("observer saw {checkpoint_events} checkpoint append(s)"); |
| 133 | + |
| 134 | + // 2. panic_on_attempt — panic safety: the panic becomes an error, fails fast. ------- |
| 135 | + let panicky = Workflow::bare() |
| 136 | + .register(Step::Start, panic_on_attempt(1, Step::Done)) |
| 137 | + .add_exit_state(Step::Done); |
| 138 | + match panicky.orchestrate(Step::Start).await { |
| 139 | + Ok(_) => unreachable!("the task panics on its first attempt"), |
| 140 | + Err(e) => println!("panic_on_attempt surfaced as error: {e}"), |
| 141 | + } |
| 142 | + |
| 143 | + // 3. assert_compensation_ran — a saga that rolls back in reverse. -------------------- |
| 144 | + let log = CompensationLog::default(); |
| 145 | + let handle = log.clone(); |
| 146 | + let saga_resources = Resources::new().insert("log", log); |
| 147 | + let saga = Workflow::new(saga_resources) |
| 148 | + .register_with_compensation(Step::Start, Reserve) |
| 149 | + .register_with_compensation(Step::Work, Charge) |
| 150 | + .register(Step::Finish, Boom) // fails → drains the compensation stack in reverse |
| 151 | + .add_exit_state(Step::Done); |
| 152 | + let _ = saga.orchestrate(Step::Start).await; // expected to fail and roll back |
| 153 | + |
| 154 | + let ran = handle.0.lock().unwrap().clone(); |
| 155 | + // Charge ran last, so it compensates first; then Reserve. |
| 156 | + assert_compensation_ran(&ran, &["charge", "reserve"]); |
| 157 | + println!("compensation ran in order: {ran:?}"); |
| 158 | + |
| 159 | + println!("\nall testing helpers exercised ✔"); |
| 160 | + Ok(()) |
| 161 | +} |
0 commit comments