Skip to main content

outbox_core/idempotency/
strategy.rs

1//! Resolves an [`IdempotencyStrategy`] into a concrete token at write time.
2//!
3//! The logic is intentionally kept out of
4//! [`OutboxService::add_event`](crate::service::OutboxService::add_event) so
5//! the strategy can be exercised directly in tests without spinning up a full
6//! service.
7
8use crate::config::IdempotencyStrategy;
9use crate::model::Event;
10use serde::Serialize;
11use std::fmt::Debug;
12
13impl<P> IdempotencyStrategy<P>
14where
15    P: Debug + Clone + Serialize,
16{
17    /// Resolves the strategy into a concrete token for the event about to be
18    /// written.
19    ///
20    /// Behaviour per variant:
21    ///
22    /// - [`Provided`](IdempotencyStrategy::Provided) — returns
23    ///   `provided_token` as-is; `None` propagates through and means the
24    ///   event will be stored without a token.
25    /// - [`Uuid`](IdempotencyStrategy::Uuid) — generates a fresh UUID v7;
26    ///   `provided_token` is ignored.
27    /// - [`Custom`](IdempotencyStrategy::Custom) — invokes `get_event`,
28    ///   passes the resulting [`Event`] to the user-supplied function, and
29    ///   wraps the returned `String` in `Some`.
30    /// - [`None`](IdempotencyStrategy::None) — returns `None`; neither
31    ///   `provided_token` nor `get_event` is used.
32    ///
33    /// `get_event` is only evaluated for the `Custom` branch, so callers can
34    /// pass `|| None` for every other strategy.
35    ///
36    /// # Panics
37    ///
38    /// Panics if the strategy is set to `Custom`, but the provided `get_event`
39    /// closure returns `None`. The panic message is
40    /// `"Strategy is Custom, but no Event context provided"`.
41    pub fn invoke<F>(&self, provided_token: Option<String>, get_event: F) -> Option<String>
42    where
43        F: FnOnce() -> Option<Event<P>>,
44    {
45        match self {
46            IdempotencyStrategy::Provided => provided_token,
47            IdempotencyStrategy::Custom(f) => {
48                let event = get_event().expect("Strategy is Custom, but no Event context provided");
49                Some(f(&event))
50            }
51            IdempotencyStrategy::Uuid => Some(uuid::Uuid::now_v7().to_string()),
52            // IdempotencyStrategy::HashPayload => {
53            //     Some("hash_payload".to_string())
54            // }
55            IdempotencyStrategy::None => None,
56        }
57    }
58}
59
60#[cfg(test)]
61#[allow(clippy::unwrap_used)]
62mod tests {
63    use super::*;
64    use crate::object::{EventType, Payload};
65    use rstest::rstest;
66    use serde::{Deserialize, Serialize};
67    use std::cell::Cell;
68
69    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70    struct TestPayload(String);
71
72    fn test_event() -> Event<TestPayload> {
73        Event::new(
74            EventType::new("t"),
75            Payload::new(TestPayload("p".into())),
76            None,
77        )
78    }
79
80    #[rstest]
81    fn provided_returns_passed_token() {
82        let s = IdempotencyStrategy::<TestPayload>::Provided;
83        assert_eq!(
84            s.invoke(Some("abc".into()), || None),
85            Some("abc".to_string())
86        );
87    }
88
89    #[rstest]
90    fn provided_returns_none_when_no_token_passed() {
91        let s = IdempotencyStrategy::<TestPayload>::Provided;
92        assert_eq!(s.invoke(None, || None), None);
93    }
94
95    #[rstest]
96    fn provided_does_not_invoke_get_event() {
97        let s = IdempotencyStrategy::<TestPayload>::Provided;
98        let _ = s.invoke(Some("x".into()), || panic!("get_event must not be called"));
99    }
100
101    #[rstest]
102    fn uuid_generates_non_empty_token() {
103        let s = IdempotencyStrategy::<TestPayload>::Uuid;
104        let token = s.invoke(None, || None).expect("Uuid must yield Some");
105        assert!(!token.is_empty());
106        // Должен парситься как UUID.
107        assert!(
108            uuid::Uuid::parse_str(&token).is_ok(),
109            "not a valid UUID: {token}"
110        );
111    }
112
113    #[rstest]
114    fn uuid_generates_unique_tokens_across_calls() {
115        let s = IdempotencyStrategy::<TestPayload>::Uuid;
116        let t1 = s.invoke(None, || None).unwrap();
117        let t2 = s.invoke(None, || None).unwrap();
118        assert_ne!(t1, t2);
119    }
120
121    #[rstest]
122    fn uuid_ignores_provided_token_and_does_not_invoke_get_event() {
123        let s = IdempotencyStrategy::<TestPayload>::Uuid;
124        let token = s
125            .invoke(Some("user-tok".into()), || {
126                panic!("get_event must not be called")
127            })
128            .unwrap();
129        assert_ne!(token, "user-tok");
130    }
131
132    #[rstest]
133    fn custom_invokes_closure_and_derives_token_from_event() {
134        fn derive(e: &Event<TestPayload>) -> String {
135            format!("d:{}", e.payload.as_value().0)
136        }
137        let s = IdempotencyStrategy::<TestPayload>::Custom(derive);
138        let called = Cell::new(false);
139        let result = s.invoke(None, || {
140            called.set(true);
141            Some(test_event())
142        });
143        assert!(called.get());
144        assert_eq!(result, Some("d:p".to_string()));
145    }
146
147    #[rstest]
148    fn custom_ignores_provided_token() {
149        fn derive(_: &Event<TestPayload>) -> String {
150            "from-closure".into()
151        }
152        let s = IdempotencyStrategy::<TestPayload>::Custom(derive);
153        let result = s.invoke(Some("user".into()), || Some(test_event()));
154        assert_eq!(result, Some("from-closure".to_string()));
155    }
156
157    #[rstest]
158    #[should_panic(expected = "Strategy is Custom, but no Event context provided")]
159    fn custom_panics_when_get_event_returns_none() {
160        fn derive(_: &Event<TestPayload>) -> String {
161            "x".into()
162        }
163        let s = IdempotencyStrategy::<TestPayload>::Custom(derive);
164        let _ = s.invoke(None, || None);
165    }
166
167    #[rstest]
168    fn none_returns_none_and_ignores_inputs() {
169        let s = IdempotencyStrategy::<TestPayload>::None;
170        assert_eq!(s.invoke(Some("x".into()), || None), None);
171    }
172
173    #[rstest]
174    fn none_does_not_invoke_get_event() {
175        let s = IdempotencyStrategy::<TestPayload>::None;
176        let _ = s.invoke(None, || panic!("get_event must not be called"));
177    }
178}