Skip to main content

lenso_test/
faults.rs

1//! Explicit, one-shot fault injection at test-owned boundaries.
2
3use std::{
4    cell::RefCell,
5    collections::{BTreeMap, VecDeque},
6    rc::Rc,
7};
8
9/// A small, target-neutral vocabulary for normal scenario fault boundaries.
10///
11/// Callers can still use a project-specific bounded label through
12/// [`FaultInjector::inject`], but this type covers the lifecycle boundaries
13/// that every deterministic scenario should name consistently.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum ScenarioBoundary {
16    /// Immediately before the business operation starts.
17    BeforeOperation,
18    /// Immediately after a durable mutation commits.
19    AfterDurableCommit,
20    /// Immediately before the response is projected to the caller.
21    BeforeResponse,
22    /// While a streaming response is in progress.
23    DuringResponse,
24    /// At the scenario deadline boundary.
25    Timeout,
26    /// At cooperative cancellation.
27    Cancellation,
28    /// When a client connection drops.
29    DroppedConnection,
30    /// When generation-owned cleanup runs.
31    Cleanup,
32    /// When a required resource is acquired.
33    ResourceAcquire,
34}
35
36impl ScenarioBoundary {
37    const fn label(self) -> &'static str {
38        match self {
39            Self::BeforeOperation => "before-operation",
40            Self::AfterDurableCommit => "after-durable-commit",
41            Self::BeforeResponse => "before-response",
42            Self::DuringResponse => "during-response",
43            Self::Timeout => "timeout",
44            Self::Cancellation => "cancellation",
45            Self::DroppedConnection => "dropped-connection",
46            Self::Cleanup => "cleanup",
47            Self::ResourceAcquire => "resource-acquire",
48        }
49    }
50}
51
52/// A bounded failure condition that a test may inject at an explicit boundary.
53///
54/// The receiving Adapter or Plugin test support decides how to project this
55/// condition into its domain. The simulator does not invent transport, storage,
56/// or cleanup behavior on its behalf.
57#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub enum SimulatorFault {
59    /// The operation's configured deadline elapsed.
60    Timeout,
61    /// The caller or generation requested cooperative cancellation.
62    Cancellation,
63    /// The peer disconnected before it observed a response.
64    DroppedConnection,
65    /// Generation-owned cleanup reported a failure.
66    CleanupFailure,
67    /// A required private resource could not be acquired.
68    ResourceUnavailable,
69}
70
71/// Error returned when a caller names an unsafe or unstable scenario boundary.
72#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub struct FaultPointError;
74
75impl std::fmt::Display for FaultPointError {
76    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        formatter.write_str("fault point must be a bounded ASCII scenario label")
78    }
79}
80
81impl std::error::Error for FaultPointError {}
82
83/// Test-owned, FIFO fault queues indexed by a stable boundary label.
84///
85/// A queued fault is consumed only when [`Self::check`] reaches the matching
86/// boundary. No fault is implicit, and an empty queue always permits work.
87#[derive(Clone, Debug, Default)]
88pub struct FaultInjector {
89    queues: Rc<RefCell<BTreeMap<String, VecDeque<SimulatorFault>>>>,
90}
91
92impl FaultInjector {
93    /// Creates an empty injector that permits every boundary.
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    /// Queues one fault for the next visit to `point`.
99    pub fn inject(&self, point: &str, fault: SimulatorFault) -> Result<(), FaultPointError> {
100        validate_point(point)?;
101        self.queues
102            .borrow_mut()
103            .entry(point.to_owned())
104            .or_default()
105            .push_back(fault);
106        Ok(())
107    }
108
109    /// Queues one fault at a canonical boundary belonging to `operation`.
110    pub fn inject_at(
111        &self,
112        operation: &str,
113        boundary: ScenarioBoundary,
114        fault: SimulatorFault,
115    ) -> Result<(), FaultPointError> {
116        self.inject(&format!("{operation}.{}", boundary.label()), fault)
117    }
118
119    /// Consumes and returns the next configured fault at `point`, if any.
120    pub fn check(&self, point: &str) -> Result<Result<(), SimulatorFault>, FaultPointError> {
121        validate_point(point)?;
122        let mut queues = self.queues.borrow_mut();
123        let fault = queues.get_mut(point).and_then(VecDeque::pop_front);
124        if queues.get(point).is_some_and(VecDeque::is_empty) {
125            queues.remove(point);
126        }
127        Ok(fault.map_or(Ok(()), Err))
128    }
129
130    /// Checks the next fault at a canonical boundary belonging to `operation`.
131    pub fn check_at(
132        &self,
133        operation: &str,
134        boundary: ScenarioBoundary,
135    ) -> Result<Result<(), SimulatorFault>, FaultPointError> {
136        self.check(&format!("{operation}.{}", boundary.label()))
137    }
138
139    /// Returns whether a boundary still has queued faults.
140    pub fn has_pending(&self, point: &str) -> Result<bool, FaultPointError> {
141        validate_point(point)?;
142        Ok(self
143            .queues
144            .borrow()
145            .get(point)
146            .is_some_and(|queue| !queue.is_empty()))
147    }
148}
149
150pub(crate) fn validate_point(point: &str) -> Result<(), FaultPointError> {
151    if point.is_empty()
152        || point.len() > 128
153        || !point
154            .bytes()
155            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':'))
156    {
157        return Err(FaultPointError);
158    }
159    Ok(())
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn faults_are_consumed_once_in_test_selected_order() {
168        let faults = FaultInjector::new();
169        faults
170            .inject(
171                "oauth.after-durable-commit",
172                SimulatorFault::DroppedConnection,
173            )
174            .unwrap();
175        faults
176            .inject("oauth.after-durable-commit", SimulatorFault::Timeout)
177            .unwrap();
178
179        assert!(faults.has_pending("oauth.after-durable-commit").unwrap());
180        assert_eq!(
181            faults.check("oauth.after-durable-commit").unwrap(),
182            Err(SimulatorFault::DroppedConnection)
183        );
184        assert_eq!(
185            faults.check("oauth.after-durable-commit").unwrap(),
186            Err(SimulatorFault::Timeout)
187        );
188        assert_eq!(faults.check("oauth.after-durable-commit").unwrap(), Ok(()));
189        assert!(!faults.has_pending("oauth.after-durable-commit").unwrap());
190    }
191
192    #[test]
193    fn canonical_boundaries_do_not_hide_the_operation_identity() {
194        let faults = FaultInjector::new();
195        faults
196            .inject_at(
197                "oauth.consume",
198                ScenarioBoundary::AfterDurableCommit,
199                SimulatorFault::DroppedConnection,
200            )
201            .unwrap();
202
203        assert_eq!(
204            faults
205                .check_at("oauth.consume", ScenarioBoundary::AfterDurableCommit)
206                .unwrap(),
207            Err(SimulatorFault::DroppedConnection)
208        );
209        assert_eq!(
210            faults
211                .check_at("oauth.create", ScenarioBoundary::AfterDurableCommit)
212                .unwrap(),
213            Ok(())
214        );
215    }
216
217    #[test]
218    fn fault_points_reject_unbounded_or_sensitive_shape() {
219        let faults = FaultInjector::new();
220
221        assert_eq!(
222            faults.inject("before response", SimulatorFault::Timeout),
223            Err(FaultPointError)
224        );
225        assert_eq!(faults.check(""), Err(FaultPointError));
226    }
227}