outbox_core/idempotency/
strategy.rs1use crate::config::IdempotencyStrategy;
9use crate::model::Event;
10
11impl<P> IdempotencyStrategy<P> {
12 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::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 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}