mako_engine/outbox.rs
1//! Outbox pattern for reliable at-least-once outbound message delivery.
2//!
3//! # Why the outbox?
4//!
5//! When a process transition generates an outbound EDIFACT message (e.g. an
6//! APERAK acknowledgement), two writes must happen atomically:
7//!
8//! 1. Domain events are appended to the event store.
9//! 2. The EDIFACT payload is queued for delivery to the AS4 endpoint.
10//!
11//! Without the outbox, a crash between steps 1 and 2 silently loses the
12//! outbound message. With the outbox, both writes are part of the same
13//! database transaction — a background delivery worker then delivers pending
14//! messages, surviving crashes and transient AS4 failures transparently.
15//!
16//! # Usage
17//!
18//! ```rust,ignore
19//! // After a command dispatch that should trigger an outbound APERAK:
20//! let env = &aperak_envelopes[0];
21//! let msg = OutboxMessage::new(
22//! process.stream_id().clone(),
23//! env.process_id,
24//! env.tenant_id,
25//! env.correlation_id,
26//! env.conversation_id,
27//! env.event_id,
28//! "APERAK",
29//! &recipient_gln,
30//! aperak_payload_json,
31//! );
32//! outbox_store.enqueue(&[msg]).await?;
33//!
34//! // Background delivery worker:
35//! let pending = outbox_store.pending_now(50).await?;
36//! for msg in pending {
37//! as4_client.send(&msg).await?;
38//! outbox_store.acknowledge(msg.message_id).await?;
39//! }
40//! ```
41//!
42//! # Atomicity contract
43//!
44//! `InMemoryOutboxStore` does **not** guarantee transactional atomicity
45//! with `InMemoryEventStore`. Persistent backend crates
46//! (`mako-event-store-slatedb`, `mako-event-store-postgres`) MUST enqueue
47//! messages in the same database transaction as the event append.
48
49use std::sync::Arc;
50
51#[cfg(any(test, feature = "testing"))]
52use std::collections::HashMap;
53#[cfg(any(test, feature = "testing"))]
54use tokio::sync::RwLock;
55
56use time::OffsetDateTime;
57
58use crate::{
59 error::EngineError,
60 ids::{ConversationId, CorrelationId, EventId, OutboxMessageId, ProcessId, StreamId, TenantId},
61};
62
63// ── PendingOutbox ─────────────────────────────────────────────────────────────
64
65/// A lightweight outbox message specification produced by [`Workflow::handle`].
66///
67/// [`Workflow::handle`] is a pure function: it cannot know the store-assigned
68/// fields (`event_id`, `stream_id`, `process_id`, etc.) of the events it is
69/// about to emit. `PendingOutbox` carries only the information the domain
70/// workflow can produce deterministically, without I/O or clock access.
71///
72/// The engine fills in the store-assigned fields after the event append
73/// succeeds, converting `PendingOutbox` into a fully materialised
74/// [`OutboxMessage`] inside [`SlateDbStore::append_with_outbox`].
75///
76/// # Example
77///
78/// ```rust,ignore
79/// // Inside Workflow::handle, when DispatchAperak succeeds:
80/// let outbox = vec![
81/// PendingOutbox::new("APERAK", &state.sender_party_id().to_string(), aperak_payload)
82/// .caused_by(0), // caused by the first event in this batch
83/// ];
84/// Ok(WorkflowOutput { events, outbox })
85/// ```
86///
87/// [`Workflow::handle`]: crate::workflow::Workflow::handle
88/// [`SlateDbStore::append_with_outbox`]: crate::event_store::AtomicAppend::append_with_outbox
89#[derive(Debug, Clone)]
90pub struct PendingOutbox {
91 /// EDIFACT or XML message type (e.g. `"APERAK"`, `"CONTRL"`, `"REMADV"`).
92 pub message_type: Box<str>,
93 /// GLN or EIC code of the intended recipient market participant.
94 pub recipient: Box<str>,
95 /// Domain-level message payload (JSON).
96 ///
97 /// Typically encodes the intent (e.g. positive/negative APERAK reason)
98 /// rather than the final EDIFACT bytes. The delivery worker or AS4
99 /// gateway is responsible for rendering the final wire format.
100 pub payload: serde_json::Value,
101 /// Do not deliver before this time.
102 ///
103 /// `None` means deliver immediately (as soon as the delivery worker runs).
104 /// Must not use the wall clock inside `handle` — derive from domain data
105 /// only (e.g. a schedule date carried in the command).
106 pub deliver_after: Option<OffsetDateTime>,
107 /// BO4E JSON Schema URL that describes the `payload` shape.
108 ///
109 /// Set this to the canonical BO4E schema URL when the payload is a
110 /// BO4E-typed object (e.g. `Marktlokation`, `Messlokation`). Leave
111 /// `None` for raw EDIFACT or untyped payloads.
112 ///
113 /// Example:
114 /// `"https://raw.githubusercontent.com/BO4E/BO4E-Schemas/v202607.0.0/src/bo4e_schemas/bo/Marktlokation.json"`
115 pub payload_schema: Option<Box<str>>,
116 /// Zero-based index into the concurrent events batch that caused this
117 /// outbound message.
118 ///
119 /// Used by the engine to set `causation_event_id` on the materialised
120 /// [`OutboxMessage`] from the stamped [`EventEnvelope`] at the same index.
121 /// Clamped to `events.len() - 1` when out-of-range.
122 ///
123 /// [`EventEnvelope`]: crate::envelope::EventEnvelope
124 pub caused_by_event_index: usize,
125}
126
127impl PendingOutbox {
128 /// Construct a pending outbox message for immediate delivery.
129 ///
130 /// `caused_by_event_index` defaults to `0` (first event in the batch).
131 /// Chain [`caused_by`] to change it.
132 ///
133 /// [`caused_by`]: PendingOutbox::caused_by
134 #[must_use]
135 pub fn new(
136 message_type: impl Into<Box<str>>,
137 recipient: impl Into<Box<str>>,
138 payload: serde_json::Value,
139 ) -> Self {
140 Self {
141 message_type: message_type.into(),
142 recipient: recipient.into(),
143 payload,
144 deliver_after: None,
145 payload_schema: None,
146 caused_by_event_index: 0,
147 }
148 }
149
150 /// Set the zero-based index of the event that caused this outbox message.
151 #[must_use]
152 pub fn caused_by(mut self, index: usize) -> Self {
153 self.caused_by_event_index = index;
154 self
155 }
156
157 /// Set a deferred delivery time (must be derived from domain data, not
158 /// the wall clock, to preserve `Workflow::handle` purity).
159 #[must_use]
160 pub fn with_deliver_after(mut self, deliver_after: OffsetDateTime) -> Self {
161 self.deliver_after = Some(deliver_after);
162 self
163 }
164
165 /// Attach a BO4E JSON Schema URL to the payload.
166 ///
167 /// Use this when the payload is a BO4E-typed object so the ERP adapter
168 /// can deserialise it into the correct type without inspecting the JSON.
169 #[must_use]
170 pub fn with_schema(mut self, schema_url: &'static str) -> Self {
171 self.payload_schema = Some(schema_url.into());
172 self
173 }
174}
175
176// ── OutboxMessage ─────────────────────────────────────────────────────────────
177
178/// An outbound message queued for delivery via AS4 or another channel.
179///
180/// The message carries both routing information (`recipient`, `message_type`)
181/// and full correlation metadata so the delivery worker can trace every send
182/// back to the domain event that caused it.
183///
184/// Construct with [`OutboxMessage::new`] and optionally chain
185/// [`OutboxMessage::with_deliver_after`] for deferred delivery.
186#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
187pub struct OutboxMessage {
188 /// Stable unique identifier for this outbox entry.
189 pub message_id: OutboxMessageId,
190
191 /// The process stream that produced this outbound message.
192 pub stream_id: StreamId,
193
194 /// The MaKo process instance.
195 pub process_id: ProcessId,
196
197 /// The tenant sending this message.
198 pub tenant_id: TenantId,
199
200 /// Propagated correlation root from the triggering event.
201 pub correlation_id: CorrelationId,
202
203 /// Business conversation this message belongs to (e.g. UTILMD ↔ APERAK).
204 pub conversation_id: ConversationId,
205
206 /// The persisted event that directly caused this outbound message.
207 pub causation_event_id: EventId,
208
209 /// EDIFACT or XML message type (e.g. `"APERAK"`, `"CONTRL"`, `"UTILMD"`).
210 pub message_type: Box<str>,
211
212 /// GLN or EIC code of the intended recipient market participant.
213 pub recipient: Box<str>,
214
215 /// Serialized message payload.
216 ///
217 /// Typically a JSON-encoded string of EDIFACT bytes or a structured
218 /// JSON object for non-EDIFACT channels.
219 pub payload: serde_json::Value,
220
221 /// BO4E JSON Schema URL that validates `payload`, if present.
222 ///
223 /// `None` for raw EDIFACT or untyped payloads. Set by domain workflows
224 /// via [`PendingOutbox::with_schema`] when the payload is a BO4E object.
225 #[serde(skip_serializing_if = "Option::is_none")]
226 pub payload_schema: Option<Box<str>>,
227
228 /// When this entry was created.
229 pub created_at: OffsetDateTime,
230
231 /// Do not deliver before this time.
232 ///
233 /// `None` means deliver immediately (as soon as the delivery worker runs).
234 pub deliver_after: Option<OffsetDateTime>,
235
236 /// Number of delivery attempts so far. Starts at `0`, incremented by
237 /// [`OutboxStore::reschedule`].
238 pub attempt_count: u32,
239
240 /// Workflow family name that produced this message (e.g. `"gpke-sperrung"`).
241 ///
242 /// Stamped from the `EventEnvelope::workflow_id.name` at materialisation
243 /// time. Used by the `OutboxErpWorker` to populate the `makoworkflow`
244 /// CloudEvents extension attribute, which `marktd` maps to `marktrole` for
245 /// role-scoped ERP fan-out.
246 ///
247 /// Empty string for messages materialised before this field was introduced
248 /// (backward-compatible deserialisation via `#[serde(default)]`).
249 #[serde(default)]
250 pub workflow_name: Box<str>,
251
252 /// W3C `traceparent` of the request that caused this message.
253 ///
254 /// Captured from [`crate::trace_ctx`] at creation time and injected into
255 /// outbound deliveries (ERP webhook header + CloudEvents `traceparent`
256 /// extension), so a trace started by the inbound transport continues
257 /// across the asynchronous outbox boundary.
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub trace_context: Option<Box<str>>,
260}
261
262impl OutboxMessage {
263 /// Construct a new outbox message.
264 ///
265 /// `message_id` and `created_at` are generated automatically.
266 /// `attempt_count` is initialized to `0`.
267 ///
268 /// Call [`OutboxMessage::with_deliver_after`] to schedule deferred
269 /// delivery.
270 #[allow(clippy::too_many_arguments)]
271 #[must_use]
272 pub fn new(
273 stream_id: StreamId,
274 process_id: ProcessId,
275 tenant_id: TenantId,
276 correlation_id: CorrelationId,
277 conversation_id: ConversationId,
278 causation_event_id: EventId,
279 message_type: impl Into<Box<str>>,
280 recipient: impl Into<Box<str>>,
281 payload: serde_json::Value,
282 ) -> Self {
283 Self {
284 message_id: OutboxMessageId::new(),
285 stream_id,
286 process_id,
287 tenant_id,
288 correlation_id,
289 conversation_id,
290 causation_event_id,
291 message_type: message_type.into(),
292 recipient: recipient.into(),
293 payload,
294 payload_schema: None,
295 created_at: OffsetDateTime::now_utc(),
296 deliver_after: None,
297 attempt_count: 0,
298 workflow_name: "".into(),
299 trace_context: crate::trace_ctx::current().map(Into::into),
300 }
301 }
302
303 /// Set a deferred delivery time.
304 ///
305 /// The message will not appear in [`OutboxStore::pending`] results until
306 /// `now >= deliver_after`.
307 #[must_use]
308 pub fn with_deliver_after(mut self, deliver_after: OffsetDateTime) -> Self {
309 self.deliver_after = Some(deliver_after);
310 self
311 }
312}
313
314// ── OutboxStore ───────────────────────────────────────────────────────────────
315
316/// Storage contract for outbox messages.
317///
318/// ## Atomicity requirement
319///
320/// In production deployments, calls to [`OutboxStore::enqueue`] MUST be
321/// atomic with the corresponding [`EventStore::append`] — both writes MUST
322/// succeed or both MUST fail. Implement this by sharing the same database
323/// transaction across both operations.
324///
325/// ## Delivery worker contract
326///
327/// The delivery worker loop should:
328/// 1. Call [`OutboxStore::pending_now`] to retrieve ready messages.
329/// 2. Attempt delivery to the AS4 endpoint.
330/// 3. On success: call [`OutboxStore::acknowledge`] to remove the message.
331/// 4. On transient failure: call [`OutboxStore::reschedule`] with an
332/// exponential back-off delay.
333///
334/// ## Blanket `Arc` implementation
335///
336/// `Arc<S>` implements `OutboxStore` whenever `S: OutboxStore`, so you can
337/// share a store across a delivery worker and command handlers without
338/// additional wrapper types.
339///
340/// [`EventStore::append`]: crate::event_store::EventStore::append
341#[allow(async_fn_in_trait)]
342pub trait OutboxStore: Send + Sync {
343 /// Persist `messages` durably, ready for delivery.
344 ///
345 /// In a persistent backend this MUST be called within the same
346 /// transaction as the event append.
347 ///
348 /// # Errors
349 ///
350 /// Returns [`EngineError::Outbox`] on storage failure.
351 #[must_use = "dropping an enqueue Result silently loses outbound EDIFACT messages"]
352 async fn enqueue(&self, messages: &[OutboxMessage]) -> Result<(), EngineError>;
353
354 /// Return up to `limit` messages ready for delivery as of `now`.
355 ///
356 /// A message is ready when `deliver_after` is `None` or `<= now`.
357 /// Results are ordered **oldest-first** by `created_at`.
358 ///
359 /// # Errors
360 ///
361 /// Returns [`EngineError::Outbox`] on storage failure.
362 #[must_use = "dropping a pending Result silently discards outbox delivery work"]
363 async fn pending(
364 &self,
365 limit: usize,
366 now: OffsetDateTime,
367 ) -> Result<Vec<OutboxMessage>, EngineError>;
368
369 /// Return up to `limit` messages ready for delivery right now.
370 ///
371 /// Convenience wrapper around [`OutboxStore::pending`] that uses
372 /// `OffsetDateTime::now_utc()` as the reference time.
373 ///
374 /// # Errors
375 ///
376 /// Returns [`EngineError::Outbox`] on storage failure.
377 async fn pending_now(&self, limit: usize) -> Result<Vec<OutboxMessage>, EngineError> {
378 self.pending(limit, OffsetDateTime::now_utc()).await
379 }
380
381 /// Remove a message from the outbox after successful delivery.
382 ///
383 /// Calling this with an unknown `id` is a no-op.
384 ///
385 /// # Errors
386 ///
387 /// Returns [`EngineError::Outbox`] on storage failure.
388 #[must_use = "dropping an acknowledge Result silently hides a store error"]
389 async fn acknowledge(&self, id: OutboxMessageId) -> Result<(), EngineError>;
390
391 /// Reschedule a message for a future delivery attempt.
392 ///
393 /// Implementations MUST increment `attempt_count` on the stored record.
394 /// Calling this with an unknown `id` is a no-op.
395 ///
396 /// # Errors
397 ///
398 /// Returns [`EngineError::Outbox`] on storage failure.
399 #[must_use = "dropping a reschedule Result silently hides a store error"]
400 async fn reschedule(
401 &self,
402 id: OutboxMessageId,
403 deliver_after: OffsetDateTime,
404 ) -> Result<(), EngineError>;
405
406 /// Return the total number of messages currently in the outbox.
407 ///
408 /// # Errors
409 ///
410 /// Returns [`EngineError::Outbox`] on storage failure.
411 #[must_use = "dropping a len Result silently discards a store error"]
412 async fn len(&self) -> Result<usize, EngineError>;
413
414 /// Return `true` when the outbox contains no messages.
415 ///
416 /// # Errors
417 ///
418 /// Returns [`EngineError::Outbox`] on storage failure.
419 async fn is_empty(&self) -> Result<bool, EngineError> {
420 Ok(self.len().await? == 0)
421 }
422}
423
424// ── Arc<S> blanket impl ───────────────────────────────────────────────────────
425
426impl<S: OutboxStore> OutboxStore for Arc<S> {
427 async fn enqueue(&self, messages: &[OutboxMessage]) -> Result<(), EngineError> {
428 self.as_ref().enqueue(messages).await
429 }
430
431 async fn pending(
432 &self,
433 limit: usize,
434 now: OffsetDateTime,
435 ) -> Result<Vec<OutboxMessage>, EngineError> {
436 self.as_ref().pending(limit, now).await
437 }
438
439 async fn acknowledge(&self, id: OutboxMessageId) -> Result<(), EngineError> {
440 self.as_ref().acknowledge(id).await
441 }
442
443 async fn reschedule(
444 &self,
445 id: OutboxMessageId,
446 deliver_after: OffsetDateTime,
447 ) -> Result<(), EngineError> {
448 self.as_ref().reschedule(id, deliver_after).await
449 }
450
451 async fn len(&self) -> Result<usize, EngineError> {
452 self.as_ref().len().await
453 }
454}
455
456// ── NoopOutboxStore ───────────────────────────────────────────────────────────
457
458/// An [`OutboxStore`] that silently discards all messages.
459///
460/// Every `enqueue` succeeds without storing anything. `pending` always
461/// returns an empty list. Use this as the default when outbox delivery is
462/// managed elsewhere or not required.
463///
464/// # ⚠️ Data loss warning
465///
466/// `NoopOutboxStore` **discards every outbound message silently**. No APERAK,
467/// MSCONS, or UTILMD will ever be delivered to the AS4 endpoint. Do not use
468/// in production.
469///
470/// This type is available in all build configurations so it can serve as a
471/// default type parameter in [`EngineBuilder`]. However, `EngineBuilder::new`
472/// (which wires this as the default) is only available with the `testing`
473/// feature or in `cfg(test)`. Production code must call
474/// [`EngineBuilder::with_stores`] instead.
475///
476/// [`EngineBuilder`]: crate::builder::EngineBuilder
477/// [`EngineBuilder::with_stores`]: crate::builder::EngineBuilder::with_stores
478#[derive(Debug, Clone, Copy, Default)]
479#[must_use = "NoopOutboxStore discards all outbound messages silently — use a persistent OutboxStore in production"]
480#[cfg_attr(
481 not(any(test, feature = "testing")),
482 deprecated = "NoopOutboxStore must not be instantiated in production builds; use a durable OutboxStore instead"
483)]
484pub struct NoopOutboxStore;
485
486#[cfg(any(test, feature = "testing"))]
487impl OutboxStore for NoopOutboxStore {
488 async fn enqueue(&self, _messages: &[OutboxMessage]) -> Result<(), EngineError> {
489 Ok(())
490 }
491
492 async fn pending(
493 &self,
494 _limit: usize,
495 _now: OffsetDateTime,
496 ) -> Result<Vec<OutboxMessage>, EngineError> {
497 Ok(Vec::new())
498 }
499
500 async fn acknowledge(&self, _id: OutboxMessageId) -> Result<(), EngineError> {
501 Ok(())
502 }
503
504 async fn reschedule(
505 &self,
506 _id: OutboxMessageId,
507 _deliver_after: OffsetDateTime,
508 ) -> Result<(), EngineError> {
509 Ok(())
510 }
511
512 async fn len(&self) -> Result<usize, EngineError> {
513 Ok(0)
514 }
515}
516
517// ── InMemoryOutboxStore ───────────────────────────────────────────────────────
518
519/// An in-memory [`OutboxStore`] for tests and development.
520///
521/// Backed by a `HashMap` protected by a `RwLock`. Cloning shares the
522/// underlying data via `Arc` — all clones see the same outbox state.
523///
524/// **Not production-safe.** Use this for:
525/// - Unit and integration tests
526/// - Local development and examples
527/// - Verifying the outbox delivery loop without an external message broker
528///
529/// Only available in `#[cfg(test)]` or with the `testing` feature enabled.
530#[cfg(any(test, feature = "testing"))]
531#[derive(Debug, Default, Clone)]
532pub struct InMemoryOutboxStore {
533 inner: Arc<RwLock<HashMap<OutboxMessageId, OutboxMessage>>>,
534}
535
536#[cfg(any(test, feature = "testing"))]
537impl InMemoryOutboxStore {
538 /// Create an empty outbox store.
539 #[must_use]
540 pub fn new() -> Self {
541 Self::default()
542 }
543}
544
545#[cfg(any(test, feature = "testing"))]
546impl OutboxStore for InMemoryOutboxStore {
547 async fn enqueue(&self, messages: &[OutboxMessage]) -> Result<(), EngineError> {
548 let mut map = self.inner.write().await;
549 for msg in messages {
550 map.insert(msg.message_id, msg.clone());
551 }
552 Ok(())
553 }
554
555 async fn pending(
556 &self,
557 limit: usize,
558 now: OffsetDateTime,
559 ) -> Result<Vec<OutboxMessage>, EngineError> {
560 let map = self.inner.read().await;
561 let mut ready: Vec<_> = map
562 .values()
563 .filter(|m| m.deliver_after.is_none_or(|d| d <= now))
564 .cloned()
565 .collect();
566 // Stable ordering: oldest first so the delivery worker processes in
567 // creation order, preserving causal ordering across messages.
568 ready.sort_by_key(|m| m.created_at);
569 ready.truncate(limit);
570 Ok(ready)
571 }
572
573 async fn acknowledge(&self, id: OutboxMessageId) -> Result<(), EngineError> {
574 self.inner.write().await.remove(&id);
575 Ok(())
576 }
577
578 async fn reschedule(
579 &self,
580 id: OutboxMessageId,
581 deliver_after: OffsetDateTime,
582 ) -> Result<(), EngineError> {
583 let mut map = self.inner.write().await;
584 if let Some(msg) = map.get_mut(&id) {
585 msg.deliver_after = Some(deliver_after);
586 msg.attempt_count += 1;
587 }
588 Ok(())
589 }
590
591 async fn len(&self) -> Result<usize, EngineError> {
592 Ok(self.inner.read().await.len())
593 }
594}
595
596// ── Outbox idempotency key ────────────────────────────────────────────────────
597
598/// Compute a deterministic idempotency key for an outbound message.
599///
600/// The key is a UUID v5 (SHA-1 over a stable namespace) derived from the
601/// combination of process id, workflow step name, recipient partner id, and
602/// format version. Identical inputs always produce the same UUID.
603///
604/// # Usage
605///
606/// Store the key alongside the outbox entry and use it as a unique constraint
607/// in persistent backends so that re-dispatching the same command (e.g. after
608/// a retry) does not produce duplicate outbound messages:
609///
610/// ```rust
611/// use mako_engine::outbox::outbox_idempotency_key;
612/// use mako_engine::ids::ProcessId;
613///
614/// let process_id = ProcessId::new();
615/// let key = outbox_idempotency_key(process_id, "DispatchAperak", "4012345000023", "FV2025-10-01");
616/// println!("idempotency key: {key}");
617/// ```
618///
619/// # Key derivation
620///
621/// The key is `UUID_v5(MAKO_ENGINE_OUTBOX_NS, "{process_id}|{step}|{partner}|{fv}")`.
622///
623/// `MAKO_ENGINE_OUTBOX_NS` is a fixed namespace UUID (RFC 4122 §4.3, SHA-1
624/// variant) that scopes all mako-engine outbox keys to avoid collisions with
625/// UUIDs from other namespaces.
626#[must_use]
627pub fn outbox_idempotency_key(
628 process_id: ProcessId,
629 step: &str,
630 recipient: &str,
631 format_version: &str,
632) -> uuid::Uuid {
633 // A fixed namespace UUID for mako-engine outbox keys.
634 // Generated once by uuid::Uuid::new_v4() and hardcoded for stability.
635 // Changing this constant invalidates all existing keys — treat as immutable.
636 const MAKO_ENGINE_OUTBOX_NS: uuid::Uuid = uuid::Uuid::from_bytes([
637 0xd4, 0x7a, 0x2c, 0x9e, 0x5b, 0x31, 0x47, 0xf2, 0x89, 0x0a, 0x1e, 0x6c, 0x8a, 0x3d, 0x5f,
638 0x04,
639 ]);
640 let name = format!("{process_id}|{step}|{recipient}|{format_version}");
641 uuid::Uuid::new_v5(&MAKO_ENGINE_OUTBOX_NS, name.as_bytes())
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647 use crate::ids::{ConversationId, CorrelationId, EventId, ProcessId, TenantId};
648
649 fn make_msg() -> OutboxMessage {
650 OutboxMessage::new(
651 StreamId::new("process/test"),
652 ProcessId::new(),
653 TenantId::new(),
654 CorrelationId::new(),
655 ConversationId::new(),
656 EventId::new(),
657 "APERAK",
658 "4012345000023",
659 serde_json::json!({"positive": true}),
660 )
661 }
662
663 #[tokio::test]
664 async fn enqueue_appears_in_pending() {
665 let store = InMemoryOutboxStore::new();
666 let msg = make_msg();
667 let id = msg.message_id;
668
669 store.enqueue(&[msg]).await.unwrap();
670
671 assert_eq!(store.len().await.unwrap(), 1);
672 let pending = store.pending_now(10).await.unwrap();
673 assert_eq!(pending.len(), 1);
674 assert_eq!(pending[0].message_id, id);
675 }
676
677 #[tokio::test]
678 async fn acknowledge_removes_message() {
679 let store = InMemoryOutboxStore::new();
680 let msg = make_msg();
681 let id = msg.message_id;
682
683 store.enqueue(&[msg]).await.unwrap();
684 store.acknowledge(id).await.unwrap();
685
686 assert!(store.is_empty().await.unwrap());
687 }
688
689 #[tokio::test]
690 async fn deferred_message_not_in_pending_yet() {
691 let store = InMemoryOutboxStore::new();
692 let future = OffsetDateTime::now_utc() + time::Duration::hours(1);
693 let msg = make_msg().with_deliver_after(future);
694
695 store.enqueue(&[msg]).await.unwrap();
696
697 let pending = store.pending_now(10).await.unwrap();
698 assert!(
699 pending.is_empty(),
700 "deferred message must not appear before its time"
701 );
702 }
703
704 #[tokio::test]
705 async fn deferred_message_appears_after_deadline() {
706 let store = InMemoryOutboxStore::new();
707 let past = OffsetDateTime::now_utc() - time::Duration::seconds(1);
708 let msg = make_msg().with_deliver_after(past);
709
710 store.enqueue(&[msg]).await.unwrap();
711
712 let pending = store.pending_now(10).await.unwrap();
713 assert_eq!(pending.len(), 1);
714 }
715
716 #[tokio::test]
717 async fn reschedule_increments_attempt_count() {
718 let store = InMemoryOutboxStore::new();
719 let msg = make_msg();
720 let id = msg.message_id;
721 let new_time = OffsetDateTime::now_utc() + time::Duration::minutes(5);
722
723 store.enqueue(&[msg]).await.unwrap();
724 store.reschedule(id, new_time).await.unwrap();
725
726 let inner = store.inner.read().await;
727 let stored = inner.get(&id).unwrap();
728 assert_eq!(stored.attempt_count, 1);
729 assert_eq!(stored.deliver_after, Some(new_time));
730 }
731
732 #[tokio::test]
733 async fn pending_ordered_oldest_first() {
734 let store = InMemoryOutboxStore::new();
735 store.enqueue(&[make_msg()]).await.unwrap();
736 store.enqueue(&[make_msg()]).await.unwrap();
737
738 let pending = store.pending_now(10).await.unwrap();
739 assert_eq!(pending.len(), 2);
740 assert!(pending[0].created_at <= pending[1].created_at);
741 }
742
743 #[test]
744 fn outbox_idempotency_key_is_stable_and_deterministic() {
745 let pid = ProcessId::new();
746 let step = "ReceiveAperak";
747 let partner = "4012345000023";
748 let fv = "FV2025-10-01";
749
750 let k1 = outbox_idempotency_key(pid, step, partner, fv);
751 let k2 = outbox_idempotency_key(pid, step, partner, fv);
752 assert_eq!(k1, k2, "same inputs must produce the same key");
753 assert_eq!(k1.to_string().len(), 36, "UUID string is 36 chars");
754
755 // Different step → different key.
756 let k3 = outbox_idempotency_key(pid, "ReceiveContrl", partner, fv);
757 assert_ne!(k1, k3, "different step must produce different key");
758
759 // Different FV → different key.
760 let k4 = outbox_idempotency_key(pid, step, partner, "FV2026-10-01");
761 assert_ne!(k1, k4, "different FV must produce different key");
762 }
763}