Skip to main content

af_notify/
testing.rs

1//! In-memory notification store and senders for consumer tests.
2
3use std::collections::BTreeMap;
4use std::sync::Mutex;
5use std::time::Duration;
6
7use af_context::{NotificationId, RequestContext};
8use async_trait::async_trait;
9use chrono::Utc;
10
11use crate::{
12    AttemptEvent, DeliveryClass, DeliveryContext, DurableOutbox, NewOutboxItem, Notification,
13    NotificationStatus, NotifyError, NotifyStoreError, OutboxItem, Sender,
14};
15
16/// In-memory durable queue with lease fencing.
17pub struct MemoryOutbox {
18    notifications: bool,
19    items: Mutex<BTreeMap<NotificationId, OutboxItem>>,
20    idempotency: Mutex<BTreeMap<(String, String, String), NotificationId>>,
21    changes: tokio::sync::broadcast::Sender<NotificationId>,
22}
23
24impl Default for MemoryOutbox {
25    fn default() -> Self {
26        let (changes, _) = tokio::sync::broadcast::channel(32);
27        Self {
28            notifications: true,
29            items: Mutex::new(BTreeMap::new()),
30            idempotency: Mutex::new(BTreeMap::new()),
31            changes,
32        }
33    }
34}
35
36impl MemoryOutbox {
37    /// Model a worker in another process: durable reads work without local broadcasts.
38    pub fn without_notifications() -> Self {
39        Self {
40            notifications: false,
41            ..Self::default()
42        }
43    }
44}
45
46#[async_trait]
47impl DurableOutbox for MemoryOutbox {
48    fn subscribe(&self) -> tokio::sync::broadcast::Receiver<NotificationId> {
49        if self.notifications {
50            self.changes.subscribe()
51        } else {
52            let (_, receiver) = tokio::sync::broadcast::channel(1);
53            receiver
54        }
55    }
56    async fn enqueue(&self, item: NewOutboxItem) -> Result<OutboxItem, NotifyStoreError> {
57        let key = (
58            item.tenant_id.to_string(),
59            item.subject_id.to_string(),
60            item.idempotency_key.clone(),
61        );
62        let mut idempotency = self
63            .idempotency
64            .lock()
65            .unwrap_or_else(std::sync::PoisonError::into_inner);
66        let mut items = self
67            .items
68            .lock()
69            .unwrap_or_else(std::sync::PoisonError::into_inner);
70        if let Some(existing) = idempotency.get(&key).and_then(|id| items.get(id)) {
71            return if existing.channel == item.channel
72                && existing.recipient == item.recipient
73                && existing.notification == item.notification
74                && existing.max_attempts == item.max_attempts.max(1)
75            {
76                Ok(existing.clone())
77            } else {
78                Err(NotifyStoreError::IdempotencyConflict)
79            };
80        }
81        let id = NotificationId::parse(format!("notification-{}", items.len() + 1))
82            .map_err(|error| NotifyStoreError::Invalid(error.to_string()))?;
83        let now = Utc::now();
84        let record = OutboxItem {
85            id: id.clone(),
86            tenant_id: item.tenant_id,
87            subject_id: item.subject_id,
88            channel: item.channel,
89            recipient: item.recipient,
90            notification: item.notification,
91            status: NotificationStatus::Pending,
92            attempts: 0,
93            max_attempts: item.max_attempts.max(1),
94            lease_version: 0,
95            last_error: None,
96            created_at: now,
97            updated_at: now,
98        };
99        items.insert(id.clone(), record.clone());
100        idempotency.insert(key, id);
101        let _ = self.changes.send(record.id.clone());
102        Ok(record)
103    }
104    async fn get(
105        &self,
106        context: &RequestContext,
107        id: &NotificationId,
108    ) -> Result<OutboxItem, NotifyStoreError> {
109        self.items
110            .lock()
111            .unwrap_or_else(std::sync::PoisonError::into_inner)
112            .get(id)
113            .filter(|item| {
114                item.tenant_id == context.tenant_id && item.subject_id == context.subject_id
115            })
116            .cloned()
117            .ok_or(NotifyStoreError::NotFound)
118    }
119    async fn list(
120        &self,
121        context: &RequestContext,
122        status: Option<NotificationStatus>,
123        limit: usize,
124        after: Option<&NotificationId>,
125    ) -> Result<Vec<OutboxItem>, NotifyStoreError> {
126        Ok(self
127            .items
128            .lock()
129            .unwrap_or_else(std::sync::PoisonError::into_inner)
130            .values()
131            .filter(|item| {
132                item.tenant_id == context.tenant_id
133                    && item.subject_id == context.subject_id
134                    && status.is_none_or(|status| item.status == status)
135                    && after.is_none_or(|after| item.id > *after)
136            })
137            .take(limit.clamp(1, 101))
138            .cloned()
139            .collect())
140    }
141    async fn attempts(
142        &self,
143        context: &RequestContext,
144        id: &NotificationId,
145    ) -> Result<Vec<AttemptEvent>, NotifyStoreError> {
146        self.get(context, id).await.map(|_| Vec::new())
147    }
148    async fn claim(
149        &self,
150        _worker_id: &str,
151        _lease_secs: i64,
152        batch: usize,
153    ) -> Result<Vec<OutboxItem>, NotifyStoreError> {
154        let mut items = self
155            .items
156            .lock()
157            .unwrap_or_else(std::sync::PoisonError::into_inner);
158        let ids: Vec<_> = items
159            .values()
160            .filter(|item| {
161                matches!(
162                    item.status,
163                    NotificationStatus::Pending | NotificationStatus::RetryScheduled
164                )
165            })
166            .take(batch.clamp(1, 100))
167            .map(|item| item.id.clone())
168            .collect();
169        let claimed: Vec<_> = ids
170            .into_iter()
171            .filter_map(|id| {
172                let item = items.get_mut(&id)?;
173                item.status = NotificationStatus::Sending;
174                item.attempts += 1;
175                item.lease_version += 1;
176                Some(item.clone())
177            })
178            .collect();
179        for item in &claimed {
180            let _ = self.changes.send(item.id.clone());
181        }
182        Ok(claimed)
183    }
184    async fn mark_sent(
185        &self,
186        id: &NotificationId,
187        lease_version: i64,
188    ) -> Result<(), NotifyStoreError> {
189        let mut items = self
190            .items
191            .lock()
192            .unwrap_or_else(std::sync::PoisonError::into_inner);
193        let item = items.get_mut(id).ok_or(NotifyStoreError::NotFound)?;
194        if item.lease_version != lease_version {
195            return Err(NotifyStoreError::LeaseLost);
196        }
197        item.status = NotificationStatus::Sent;
198        let _ = self.changes.send(id.clone());
199        Ok(())
200    }
201    async fn record_failure(
202        &self,
203        id: &NotificationId,
204        lease_version: i64,
205        class: DeliveryClass,
206        _delay: Duration,
207    ) -> Result<NotificationStatus, NotifyStoreError> {
208        let mut items = self
209            .items
210            .lock()
211            .unwrap_or_else(std::sync::PoisonError::into_inner);
212        let item = items.get_mut(id).ok_or(NotifyStoreError::NotFound)?;
213        if item.lease_version != lease_version {
214            return Err(NotifyStoreError::LeaseLost);
215        }
216        item.status = if class == DeliveryClass::Permanent || item.attempts >= item.max_attempts {
217            NotificationStatus::DeadLetter
218        } else {
219            NotificationStatus::RetryScheduled
220        };
221        let _ = self.changes.send(id.clone());
222        Ok(item.status)
223    }
224    async fn retry_dead_letter(
225        &self,
226        context: &RequestContext,
227        id: &NotificationId,
228    ) -> Result<OutboxItem, NotifyStoreError> {
229        let mut items = self
230            .items
231            .lock()
232            .unwrap_or_else(std::sync::PoisonError::into_inner);
233        let item = items
234            .get_mut(id)
235            .filter(|item| item.tenant_id == context.tenant_id)
236            .ok_or(NotifyStoreError::NotFound)?;
237        if item.status != NotificationStatus::DeadLetter {
238            return Err(NotifyStoreError::Invalid(
239                "notification is not dead-lettered".into(),
240            ));
241        }
242        item.status = NotificationStatus::Pending;
243        item.attempts = 0;
244        let _ = self.changes.send(id.clone());
245        Ok(item.clone())
246    }
247}
248
249/// Sender that captures rendered deliveries.
250#[derive(Default)]
251pub struct CapturingSender {
252    /// Captured recipient and body pairs.
253    pub deliveries: Mutex<Vec<(String, String)>>,
254}
255
256#[async_trait]
257impl Sender for CapturingSender {
258    fn name(&self) -> &str {
259        "capture"
260    }
261    async fn send(
262        &self,
263        context: &DeliveryContext,
264        recipient: &str,
265        notification: &Notification,
266    ) -> Result<(), NotifyError> {
267        if context.cancellation.is_cancelled() {
268            return Err(NotifyError::Cancelled);
269        }
270        self.deliveries
271            .lock()
272            .unwrap_or_else(std::sync::PoisonError::into_inner)
273            .push((recipient.to_owned(), notification.to_plain_text()));
274        Ok(())
275    }
276}
277
278/// Sender that always returns one configured failure.
279pub struct FailingSender(pub NotifyError);
280
281#[async_trait]
282impl Sender for FailingSender {
283    fn name(&self) -> &str {
284        "fail"
285    }
286    async fn send(
287        &self,
288        _context: &DeliveryContext,
289        _recipient: &str,
290        _notification: &Notification,
291    ) -> Result<(), NotifyError> {
292        Err(self.0.clone())
293    }
294}