Skip to main content

aion_server/dev_ui/
mock.rs

1//! Opt-in per-run activity mocking, layered over the production dispatcher.
2//!
3//! The mock is a thin decorator on the real [`aion::ActivityDispatcher`]: a
4//! [`DevMockingDispatcher`] wraps the production [`WorkerActivityDispatcher`]
5//! and consults a shared [`ActivityMockRegistry`] before every dispatch. When a
6//! mock is registered for the dispatch's `(workflow_id, activity_name)`, the
7//! canned result is returned and the real worker is never contacted; otherwise
8//! the dispatch is delegated to the wrapped dispatcher unchanged.
9//!
10//! This deliberately changes nothing in the engine (CN4): the engine still
11//! schedules, records, and replays the activity through its single Recorder
12//! exactly as in production — the recorded `ActivityCompleted` event for a
13//! mocked activity is indistinguishable from a real worker's completion, so a
14//! replay or a server restart re-drives the run identically. The mock only
15//! short-circuits the transport-side worker round-trip for one run.
16//!
17//! Mocks are keyed by the *real* [`WorkflowId`] the engine recorded, so they
18//! scope to exactly the run a developer triggered (each dev trigger starts a
19//! fresh workflow id) and never leak across runs.
20
21use std::collections::HashMap;
22use std::sync::{Arc, Mutex};
23
24use aion::{ActivityDispatch, ActivityDispatcher};
25use aion_core::WorkflowId;
26
27/// A canned activity outcome installed for one workflow run.
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub enum MockedActivity {
30    /// The activity returns this JSON-encoded result string verbatim — the same
31    /// shape a real worker reports on the success side of the FFI contract.
32    Succeeds {
33        /// JSON-encoded typed result returned to the workflow.
34        result_json: String,
35    },
36    /// The activity fails with this error message — the failure side of the FFI
37    /// contract the SDK decodes.
38    Fails {
39        /// Human-readable failure message returned to the workflow.
40        message: String,
41    },
42}
43
44/// Key identifying a mock: a specific activity name within a specific run.
45#[derive(Clone, Debug, Eq, Hash, PartialEq)]
46struct MockKey {
47    workflow_id: WorkflowId,
48    activity_name: String,
49}
50
51/// Shared, mutable registry of per-run activity mocks consulted on every
52/// dispatch. Cloned freely; all clones share one table.
53#[derive(Clone, Default)]
54pub struct ActivityMockRegistry {
55    mocks: Arc<Mutex<HashMap<MockKey, MockedActivity>>>,
56}
57
58impl std::fmt::Debug for ActivityMockRegistry {
59    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        formatter
61            .debug_struct("ActivityMockRegistry")
62            .finish_non_exhaustive()
63    }
64}
65
66impl ActivityMockRegistry {
67    /// Build an empty registry.
68    #[must_use]
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Install a mock for `activity_name` within `workflow_id`, replacing any
74    /// previously registered mock for the same pair.
75    ///
76    /// # Errors
77    ///
78    /// Returns the lock-poison message when the registry mutex was poisoned by
79    /// a panic in another holder.
80    pub fn register(
81        &self,
82        workflow_id: WorkflowId,
83        activity_name: impl Into<String>,
84        mock: MockedActivity,
85    ) -> Result<(), String> {
86        let key = MockKey {
87            workflow_id,
88            activity_name: activity_name.into(),
89        };
90        self.mocks
91            .lock()
92            .map_err(|_| "activity mock registry mutex poisoned".to_owned())?
93            .insert(key, mock);
94        Ok(())
95    }
96
97    /// Look up the mock for a dispatch, if one is installed.
98    ///
99    /// # Errors
100    ///
101    /// Returns the lock-poison message when the registry mutex was poisoned.
102    fn lookup(
103        &self,
104        workflow_id: &WorkflowId,
105        activity_name: &str,
106    ) -> Result<Option<MockedActivity>, String> {
107        let guard = self
108            .mocks
109            .lock()
110            .map_err(|_| "activity mock registry mutex poisoned".to_owned())?;
111        // The key borrows owned fields, so build it from the lookup inputs.
112        Ok(guard
113            .get(&MockKey {
114                workflow_id: workflow_id.clone(),
115                activity_name: activity_name.to_owned(),
116            })
117            .cloned())
118    }
119
120    /// Whether any mock is currently registered for `workflow_id`.
121    ///
122    /// # Errors
123    ///
124    /// Returns the lock-poison message when the registry mutex was poisoned.
125    pub fn has_any_for(&self, workflow_id: &WorkflowId) -> Result<bool, String> {
126        let guard = self
127            .mocks
128            .lock()
129            .map_err(|_| "activity mock registry mutex poisoned".to_owned())?;
130        Ok(guard.keys().any(|key| &key.workflow_id == workflow_id))
131    }
132}
133
134/// Production dispatcher wrapped with per-run activity mocking.
135///
136/// Installed in place of the bare [`WorkerActivityDispatcher`] only when the
137/// dev surface is commissioned. With no mock registered for a dispatch it is a
138/// transparent pass-through, so a server running the dev surface but with no
139/// active mocks behaves exactly as production.
140pub struct DevMockingDispatcher {
141    inner: Arc<dyn ActivityDispatcher>,
142    registry: ActivityMockRegistry,
143}
144
145impl std::fmt::Debug for DevMockingDispatcher {
146    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        formatter
148            .debug_struct("DevMockingDispatcher")
149            .field("registry", &self.registry)
150            .finish_non_exhaustive()
151    }
152}
153
154impl DevMockingDispatcher {
155    /// Wrap `inner` with the shared mock registry.
156    #[must_use]
157    pub fn new(inner: Arc<dyn ActivityDispatcher>, registry: ActivityMockRegistry) -> Self {
158        Self { inner, registry }
159    }
160}
161
162impl ActivityDispatcher for DevMockingDispatcher {
163    fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
164        match self.registry.lookup(&request.workflow_id, &request.name)? {
165            Some(MockedActivity::Succeeds { result_json }) => {
166                tracing::info!(
167                    operation = "dev.activity_mock",
168                    workflow_id = %request.workflow_id,
169                    activity_id = %request.activity_id,
170                    activity_type = %request.name,
171                    outcome = "succeeded",
172                    "dev activity mock returned a canned result"
173                );
174                Ok(result_json)
175            }
176            Some(MockedActivity::Fails { message }) => {
177                tracing::info!(
178                    operation = "dev.activity_mock",
179                    workflow_id = %request.workflow_id,
180                    activity_id = %request.activity_id,
181                    activity_type = %request.name,
182                    outcome = "failed",
183                    "dev activity mock returned a canned failure"
184                );
185                Err(message)
186            }
187            None => self.inner.dispatch(request),
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use std::collections::BTreeMap;
195    use std::sync::Arc;
196
197    use aion::{ActivityDispatch, ActivityDispatcher};
198    use aion_core::{ActivityId, WorkflowId};
199
200    use super::{ActivityMockRegistry, DevMockingDispatcher, MockedActivity};
201
202    /// Inner dispatcher that records every delegated call and echoes its input.
203    #[derive(Default)]
204    struct RecordingInner {
205        calls: std::sync::Mutex<Vec<String>>,
206    }
207
208    impl ActivityDispatcher for RecordingInner {
209        fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
210            self.calls
211                .lock()
212                .map_err(|_| "poisoned".to_owned())?
213                .push(request.name.clone());
214            Ok(format!("real:{}", request.input))
215        }
216    }
217
218    fn dispatch(workflow_id: WorkflowId, name: &str) -> ActivityDispatch {
219        ActivityDispatch {
220            namespace: "default".to_owned(),
221            task_queue: "default".to_owned(),
222            node: None,
223            workflow_id,
224            run_id: aion_core::RunId::new_v4(),
225            activity_id: ActivityId::from_sequence_position(0),
226            name: name.to_owned(),
227            input: "{}".to_owned(),
228            config: "{}".to_owned(),
229            attempt: 1,
230            labels: BTreeMap::new(),
231            advisory: false,
232        }
233    }
234
235    #[test]
236    fn mocked_activity_returns_canned_result_without_delegating() -> Result<(), String> {
237        let inner = Arc::new(RecordingInner::default());
238        let registry = ActivityMockRegistry::new();
239        let workflow_id = WorkflowId::new_v4();
240        registry.register(
241            workflow_id.clone(),
242            "charge-card",
243            MockedActivity::Succeeds {
244                result_json: r#"{"charged":true}"#.to_owned(),
245            },
246        )?;
247        let dispatcher = DevMockingDispatcher::new(inner.clone(), registry);
248
249        let result = dispatcher.dispatch(dispatch(workflow_id, "charge-card"));
250
251        assert_eq!(result, Ok(r#"{"charged":true}"#.to_owned()));
252        assert!(
253            inner
254                .calls
255                .lock()
256                .map_err(|_| "poisoned".to_owned())?
257                .is_empty(),
258            "a mocked activity must not reach the real dispatcher"
259        );
260        Ok(())
261    }
262
263    #[test]
264    fn mocked_failure_short_circuits_with_the_canned_message() -> Result<(), String> {
265        let inner = Arc::new(RecordingInner::default());
266        let registry = ActivityMockRegistry::new();
267        let workflow_id = WorkflowId::new_v4();
268        registry.register(
269            workflow_id.clone(),
270            "charge-card",
271            MockedActivity::Fails {
272                message: "card declined".to_owned(),
273            },
274        )?;
275        let dispatcher = DevMockingDispatcher::new(inner, registry);
276
277        assert_eq!(
278            dispatcher.dispatch(dispatch(workflow_id, "charge-card")),
279            Err("card declined".to_owned())
280        );
281        Ok(())
282    }
283
284    #[test]
285    fn unmocked_activity_delegates_to_the_real_dispatcher() -> Result<(), String> {
286        let inner = Arc::new(RecordingInner::default());
287        let registry = ActivityMockRegistry::new();
288        let dispatcher = DevMockingDispatcher::new(inner.clone(), registry);
289
290        let result = dispatcher.dispatch(dispatch(WorkflowId::new_v4(), "ship-order"));
291
292        assert_eq!(result, Ok("real:{}".to_owned()));
293        assert_eq!(
294            inner
295                .calls
296                .lock()
297                .map_err(|_| "poisoned".to_owned())?
298                .as_slice(),
299            ["ship-order"]
300        );
301        Ok(())
302    }
303
304    #[test]
305    fn mock_is_scoped_to_its_workflow_run() -> Result<(), String> {
306        let inner = Arc::new(RecordingInner::default());
307        let registry = ActivityMockRegistry::new();
308        let mocked = WorkflowId::new_v4();
309        let other = WorkflowId::new_v4();
310        registry.register(
311            mocked.clone(),
312            "charge-card",
313            MockedActivity::Succeeds {
314                result_json: r#"{"charged":true}"#.to_owned(),
315            },
316        )?;
317        let dispatcher = DevMockingDispatcher::new(inner.clone(), registry.clone());
318
319        // The same activity name on a different run is NOT mocked.
320        assert_eq!(
321            dispatcher.dispatch(dispatch(other.clone(), "charge-card")),
322            Ok("real:{}".to_owned())
323        );
324        assert!(registry.has_any_for(&mocked)?);
325        assert!(!registry.has_any_for(&other)?);
326        Ok(())
327    }
328}