Skip to main content

lenso_test/
receipt.rs

1//! Stable, bounded scenario receipts for deterministic test evidence.
2
3use std::{cell::RefCell, rc::Rc, time::Duration};
4
5use crate::{FaultPointError, SimulatorFault, TestSimulator, faults::validate_point};
6
7/// A non-sensitive lifecycle transition that a scenario may record.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum ScenarioTransition {
10    /// The operation began.
11    Started,
12    /// The operation reached a controlled pause boundary.
13    Paused,
14    /// The test released the controlled pause boundary.
15    Resumed,
16    /// The operation completed its durable mutation.
17    DurableCommit,
18    /// The operation began projecting its response.
19    ResponseStarted,
20    /// Generation-owned cleanup began.
21    CleanupStarted,
22}
23
24/// A finite terminal classification for one scenario operation.
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum ScenarioTerminal {
27    /// The operation returned normally.
28    Succeeded,
29    /// Business or admission rules rejected the operation.
30    Rejected,
31    /// Cooperative cancellation ended the operation.
32    Cancelled,
33    /// A deadline ended the operation.
34    TimedOut,
35    /// A non-domain failure ended the operation.
36    Failed,
37    /// A durable effect may have occurred but the caller lacks a final result.
38    Uncertain,
39}
40
41/// One sanitized, deterministic scenario receipt entry.
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub struct ScenarioReceiptEvent {
44    /// Virtual monotonic timestamp at which the event was recorded.
45    pub virtual_time: Duration,
46    /// Stable generation identifier, never user input or credential material.
47    pub generation_id: String,
48    /// Stable operation identifier, never request payload or credential material.
49    pub operation_id: String,
50    /// The lifecycle transition observed at this boundary.
51    pub transition: ScenarioTransition,
52    /// Explicit injected fault observed at this boundary, when applicable.
53    pub fault: Option<SimulatorFault>,
54    /// Finite outcome when this entry terminates the operation.
55    pub terminal: Option<ScenarioTerminal>,
56}
57
58/// A test-owned receipt recorder attached to one [`TestSimulator`].
59///
60/// It accepts only bounded ASCII identifiers and finite enums so test evidence
61/// cannot accidentally include request payloads, secrets, or arbitrary errors.
62#[derive(Clone, Debug)]
63pub struct ScenarioReceipt {
64    simulator: TestSimulator,
65    events: Rc<RefCell<Vec<ScenarioReceiptEvent>>>,
66}
67
68impl ScenarioReceipt {
69    pub(crate) fn new(simulator: TestSimulator) -> Self {
70        Self {
71            simulator,
72            events: Rc::new(RefCell::new(Vec::new())),
73        }
74    }
75
76    /// Records one non-terminal transition.
77    pub fn transition(
78        &self,
79        generation_id: &str,
80        operation_id: &str,
81        transition: ScenarioTransition,
82    ) -> Result<(), FaultPointError> {
83        self.record(generation_id, operation_id, transition, None, None)
84    }
85
86    /// Records one explicitly injected fault at the current boundary.
87    pub fn fault(
88        &self,
89        generation_id: &str,
90        operation_id: &str,
91        transition: ScenarioTransition,
92        fault: SimulatorFault,
93    ) -> Result<(), FaultPointError> {
94        self.record(generation_id, operation_id, transition, Some(fault), None)
95    }
96
97    /// Records one terminal result.
98    pub fn terminal(
99        &self,
100        generation_id: &str,
101        operation_id: &str,
102        terminal: ScenarioTerminal,
103    ) -> Result<(), FaultPointError> {
104        self.record(
105            generation_id,
106            operation_id,
107            ScenarioTransition::ResponseStarted,
108            None,
109            Some(terminal),
110        )
111    }
112
113    /// Returns a stable snapshot in record order.
114    pub fn events(&self) -> Vec<ScenarioReceiptEvent> {
115        self.events.borrow().clone()
116    }
117
118    fn record(
119        &self,
120        generation_id: &str,
121        operation_id: &str,
122        transition: ScenarioTransition,
123        fault: Option<SimulatorFault>,
124        terminal: Option<ScenarioTerminal>,
125    ) -> Result<(), FaultPointError> {
126        validate_point(generation_id)?;
127        validate_point(operation_id)?;
128        self.events.borrow_mut().push(ScenarioReceiptEvent {
129            virtual_time: self.simulator.now(),
130            generation_id: generation_id.to_owned(),
131            operation_id: operation_id.to_owned(),
132            transition,
133            fault,
134            terminal,
135        });
136        Ok(())
137    }
138}
139
140impl TestSimulator {
141    /// Creates an empty, stable receipt for one test scenario.
142    pub fn receipt(&self) -> ScenarioReceipt {
143        ScenarioReceipt::new(self.clone())
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn receipt_records_virtual_time_faults_and_uncertain_terminal_state() {
153        let simulator = TestSimulator::new();
154        let receipt = simulator.receipt();
155
156        receipt
157            .transition(
158                "generation-1",
159                "oauth.consume-1",
160                ScenarioTransition::Started,
161            )
162            .unwrap();
163        simulator.advance(Duration::from_millis(5));
164        receipt
165            .transition(
166                "generation-1",
167                "oauth.consume-1",
168                ScenarioTransition::DurableCommit,
169            )
170            .unwrap();
171        receipt
172            .fault(
173                "generation-1",
174                "oauth.consume-1",
175                ScenarioTransition::ResponseStarted,
176                SimulatorFault::DroppedConnection,
177            )
178            .unwrap();
179        receipt
180            .terminal(
181                "generation-1",
182                "oauth.consume-1",
183                ScenarioTerminal::Uncertain,
184            )
185            .unwrap();
186
187        assert_eq!(
188            receipt.events(),
189            vec![
190                ScenarioReceiptEvent {
191                    virtual_time: Duration::ZERO,
192                    generation_id: "generation-1".to_owned(),
193                    operation_id: "oauth.consume-1".to_owned(),
194                    transition: ScenarioTransition::Started,
195                    fault: None,
196                    terminal: None,
197                },
198                ScenarioReceiptEvent {
199                    virtual_time: Duration::from_millis(5),
200                    generation_id: "generation-1".to_owned(),
201                    operation_id: "oauth.consume-1".to_owned(),
202                    transition: ScenarioTransition::DurableCommit,
203                    fault: None,
204                    terminal: None,
205                },
206                ScenarioReceiptEvent {
207                    virtual_time: Duration::from_millis(5),
208                    generation_id: "generation-1".to_owned(),
209                    operation_id: "oauth.consume-1".to_owned(),
210                    transition: ScenarioTransition::ResponseStarted,
211                    fault: Some(SimulatorFault::DroppedConnection),
212                    terminal: None,
213                },
214                ScenarioReceiptEvent {
215                    virtual_time: Duration::from_millis(5),
216                    generation_id: "generation-1".to_owned(),
217                    operation_id: "oauth.consume-1".to_owned(),
218                    transition: ScenarioTransition::ResponseStarted,
219                    fault: None,
220                    terminal: Some(ScenarioTerminal::Uncertain),
221                },
222            ]
223        );
224    }
225}