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;
10
11impl<P> IdempotencyStrategy<P> {
12    /// Resolves the strategy into a concrete token for the event about to be
13    /// written.
14    ///
15    /// Behaviour per variant:
16    ///
17    /// - [`Provided`](IdempotencyStrategy::Provided) — returns
18    ///   `provided_token` as-is; `None` propagates through and means the
19    ///   event will be stored without a token.
20    /// - [`Uuid`](IdempotencyStrategy::Uuid) — generates a fresh UUID v7;
21    ///   `provided_token` is ignored.
22    /// - [`Custom`](IdempotencyStrategy::Custom) — passes `event` to the
23    ///   user-supplied callback and wraps the returned `String` in `Some`.
24    /// - [`None`](IdempotencyStrategy::None) — returns `None`; neither
25    ///   `provided_token` nor `event` is used.
26    pub fn invoke(&self, provided_token: Option<String>, event: &Event<P>) -> Option<String> {
27        match self {
28            IdempotencyStrategy::Provided => provided_token,
29            IdempotencyStrategy::Custom(f) => Some(f(event)),
30            IdempotencyStrategy::Uuid => Some(uuid::Uuid::now_v7().to_string()),
31            // IdempotencyStrategy::HashPayload => {
32            //     Some("hash_payload".to_string())
33            // }
34            IdempotencyStrategy::None => None,
35        }
36    }
37}
38
39#[cfg(test)]
40#[allow(clippy::unwrap_used)]
41mod tests {
42    use super::*;
43    use crate::object::{EventType, Payload};
44    use rstest::rstest;
45    use serde::{Deserialize, Serialize};
46    use std::sync::Arc;
47
48    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49    struct TestPayload(String);
50
51    fn test_event() -> Event<TestPayload> {
52        Event::new(
53            EventType::new("t"),
54            Payload::new(TestPayload("p".into())),
55            None,
56        )
57    }
58
59    #[rstest]
60    fn provided_returns_passed_token() {
61        let s = IdempotencyStrategy::<TestPayload>::Provided;
62        assert_eq!(
63            s.invoke(Some("abc".into()), &test_event()),
64            Some("abc".to_string())
65        );
66    }
67
68    #[rstest]
69    fn provided_returns_none_when_no_token_passed() {
70        let s = IdempotencyStrategy::<TestPayload>::Provided;
71        assert_eq!(s.invoke(None, &test_event()), None);
72    }
73
74    #[rstest]
75    fn uuid_generates_non_empty_token() {
76        let s = IdempotencyStrategy::<TestPayload>::Uuid;
77        let token = s.invoke(None, &test_event()).expect("Uuid must yield Some");
78        assert!(!token.is_empty());
79        // Must parse as a UUID.
80        assert!(
81            uuid::Uuid::parse_str(&token).is_ok(),
82            "not a valid UUID: {token}"
83        );
84    }
85
86    #[rstest]
87    fn uuid_generates_unique_tokens_across_calls() {
88        let s = IdempotencyStrategy::<TestPayload>::Uuid;
89        let t1 = s.invoke(None, &test_event()).unwrap();
90        let t2 = s.invoke(None, &test_event()).unwrap();
91        assert_ne!(t1, t2);
92    }
93
94    #[rstest]
95    fn uuid_ignores_provided_token() {
96        let s = IdempotencyStrategy::<TestPayload>::Uuid;
97        let token = s.invoke(Some("user-tok".into()), &test_event()).unwrap();
98        assert_ne!(token, "user-tok");
99    }
100
101    #[rstest]
102    fn custom_invokes_closure_and_derives_token_from_event() {
103        fn derive(e: &Event<TestPayload>) -> String {
104            format!("d:{}", e.payload.as_value().0)
105        }
106        let s = IdempotencyStrategy::<TestPayload>::Custom(Arc::new(derive));
107        let result = s.invoke(None, &test_event());
108        assert_eq!(result, Some("d:p".to_string()));
109    }
110
111    #[rstest]
112    fn custom_ignores_provided_token() {
113        fn derive(_: &Event<TestPayload>) -> String {
114            "from-closure".into()
115        }
116        let s = IdempotencyStrategy::<TestPayload>::Custom(Arc::new(derive));
117        let result = s.invoke(Some("user".into()), &test_event());
118        assert_eq!(result, Some("from-closure".to_string()));
119    }
120
121    #[rstest]
122    fn custom_accepts_closure_that_captures_state() {
123        let prefix = "tenant-a".to_string();
124        let s = IdempotencyStrategy::<TestPayload>::Custom(Arc::new(move |e| {
125            format!("{prefix}:{}", e.payload.as_value().0)
126        }));
127        let result = s.invoke(None, &test_event());
128        assert_eq!(result, Some("tenant-a:p".to_string()));
129    }
130
131    #[rstest]
132    fn none_returns_none_and_ignores_inputs() {
133        let s = IdempotencyStrategy::<TestPayload>::None;
134        assert_eq!(s.invoke(Some("x".into()), &test_event()), None);
135    }
136}