Skip to main content

arminius_lib_message_bus/mock/
mod.rs

1// SPDX-FileCopyrightText: 2025 BWI GmbH
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Mock implementation that combines both publish/subscribe and
6//! request/response patterns for unit testing without a NATS server.
7
8mod command;
9mod event;
10
11use std::{
12	collections::HashMap,
13	sync::{Arc, Mutex},
14};
15
16pub use command::{MockReceivedCommand, ReplyChecker};
17pub use event::{MockPublisher, MockReceivedEvent, MockSubscriber};
18use futures::{
19	Stream, StreamExt as _,
20	channel::{mpsc, oneshot},
21};
22
23use crate::{
24	Command, Error, Event, Publisher, ReceivedCommand, ReceivedEvent, Requester, Responder,
25	Subscriber, subject_matches,
26};
27
28/// A List of messages represented as a tuple of an identifier and the bytes.
29type MockPublishedMessages = Vec<(String, Vec<u8>)>;
30/// A map of senders grouped by the emitting type of the sender.
31type MockSenderHashMap = HashMap<String, Vec<mpsc::Sender<Vec<u8>>>>;
32/// A received command's payload paired with the channel its reply must be sent
33/// back on.
34type MockCommandPayload = (Vec<u8>, oneshot::Sender<Vec<u8>>);
35/// A map of command responders grouped by the command subject pattern they
36/// serve.
37type MockResponderHashMap = HashMap<String, Vec<mpsc::Sender<MockCommandPayload>>>;
38
39/// Combined in-memory message bus for unit tests.
40///
41/// Implements [`Publisher`], [`Subscriber`], [`Requester`], and [`Responder`]
42/// without a NATS server. Events published via `publish()` are delivered to any
43/// active `subscribe()` streams on the same subject, and also recorded so tests
44/// can assert on what was sent via [`MockBus::published_as`]. Commands sent via
45/// `request()` are routed to an active `respond()` stream on a matching
46/// subject, and the handler's reply is delivered back to the caller.
47///
48/// Service code that takes `impl Publisher + impl Subscriber` parameters can be
49/// fully tested without a running NATS server:
50///
51/// ```rust
52/// use arminius_lib_message_bus::{
53/// 	Event, Publisher, Subscriber, mock::MockBus,
54/// };
55/// use futures::StreamExt;
56/// use serde::{Deserialize, Serialize};
57///
58/// #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
59/// struct OrderCreated {
60/// 	order_id: String,
61/// }
62/// impl Event for OrderCreated {
63/// 	const SUBJECT: &str = "OrderCreated.v1";
64/// }
65///
66/// async fn handle_order(publisher: &impl Publisher, order_id: &str) {
67/// 	publisher
68/// 		.publish(OrderCreated { order_id: order_id.to_string() })
69/// 		.await
70/// 		.unwrap();
71/// }
72///
73/// # async fn example() {
74/// let bus = MockBus::new();
75/// handle_order(&bus, "order-42").await;
76/// let published = bus.published_as::<OrderCreated>();
77/// assert_eq!(published[0].order_id, "order-42");
78/// # }
79/// ```
80#[derive(Clone, Debug, Default)]
81pub struct MockBus {
82	/// All published messages, recorded so tests can assert on what was sent.
83	published: Arc<Mutex<MockPublishedMessages>>,
84	/// Active subscriber senders, keyed by subscribed subject pattern.
85	senders: Arc<Mutex<MockSenderHashMap>>,
86	/// Active responder senders, keyed by the subject pattern they handle.
87	responders: Arc<Mutex<MockResponderHashMap>>,
88}
89
90impl MockBus {
91	/// Creates a new empty `MockBus`.
92	#[must_use]
93	pub fn new() -> Self {
94		Self::default()
95	}
96
97	/// Returns all events whose published subject matches `E::SUBJECT`, in
98	/// order, deserialized. For per-instance events `SUBJECT` is a wildcard
99	/// pattern, so every instance's concrete subject is matched.
100	#[expect(clippy::expect_used, reason = "panicking is acceptable in test-only code")]
101	#[must_use]
102	pub fn published_as<E: Event>(&self) -> Vec<E> {
103		self.published
104			.lock()
105			.expect("lock should not be poisoned: no holder should panic while the mutex is held")
106			.iter()
107			.filter(|(subject, _)| subject_matches(E::SUBJECT, subject))
108			.filter_map(|(_, bytes)| serde_json::from_slice(bytes).ok())
109			.collect()
110	}
111}
112
113impl Publisher for MockBus {
114	#[expect(clippy::expect_used, reason = "panicking is acceptable in test-only code")]
115	async fn publish<E: Event + Clone + 'static>(&self, message: E) -> Result<(), Error> {
116		// Per-instance events publish to a concrete subject (e.g.
117		// `rooms.7.events.v1`) that differs from the wildcard `SUBJECT`;
118		// subscribers register the pattern.
119		let subject = message.subject().into_owned();
120
121		// Assert misconfiguration of subscribe subject and publish subject
122		debug_assert!(
123			subject_matches(E::SUBJECT, &subject),
124			"concrete subject {subject:?} is not covered by the subject pattern {:?}",
125			E::SUBJECT,
126		);
127
128		let bytes = message
129			.into_payload()
130			.map_err(|error| Error::SendFailed { error: error.to_string() })?;
131
132		self.published
133			.lock()
134			.expect("lock should not be poisoned: no holder should panic while the mutex is held")
135			.push((subject.clone(), bytes.clone()));
136
137		let mut senders = self
138			.senders
139			.lock()
140			.expect("lock should not be poisoned: no holder should panic while the mutex is held");
141		for (pattern, txs) in senders.iter_mut() {
142			if subject_matches(pattern, &subject) {
143				txs.retain_mut(|tx| tx.try_send(bytes.clone()).is_ok());
144			}
145		}
146
147		Ok(())
148	}
149}
150
151impl Subscriber for MockBus {
152	#[expect(clippy::expect_used, reason = "panicking is acceptable in test-only code")]
153	async fn subscribe<E: Event + 'static>(
154		&self,
155	) -> Result<
156		impl Stream<Item = Result<impl ReceivedEvent<E> + Send + Sync + 'static, Error>>
157		+ Send
158		+ 'static,
159		Error,
160	> {
161		let (tx, rx) = mpsc::channel::<Vec<u8>>(1024);
162		{
163			self.senders
164				.lock()
165				.expect(
166					"lock should not be poisoned: no holder should panic while the mutex is held",
167				)
168				.entry(E::SUBJECT.to_owned())
169				.or_default()
170				.push(tx);
171		}
172		Ok(rx.map(|bytes| {
173			let event = serde_json::from_slice::<E>(&bytes).map_err(|error| {
174				Error::ReceptionFailed { subject: E::SUBJECT.to_owned(), error: error.to_string() }
175			})?;
176			Ok(MockReceivedEvent::new(event))
177		}))
178	}
179}
180
181impl Requester for MockBus {
182	#[expect(clippy::expect_used, reason = "panicking is acceptable in test-only code")]
183	async fn request<C: Command>(&self, cmd: C) -> Result<C::Reply, Error> {
184		let subject = cmd.subject().into_owned();
185		let bytes =
186			cmd.into_payload().map_err(|error| Error::SendFailed { error: error.to_string() })?;
187		let (reply_tx, reply_rx) = oneshot::channel::<Vec<u8>>();
188
189		// Pick the first responder registered under a matching subject pattern.
190		// Cloning the sender lets us release the lock before awaiting the
191		// reply.
192		let mut sender = self
193			.responders
194			.lock()
195			.expect("lock should not be poisoned: no holder should panic while the mutex is held")
196			.iter()
197			.filter(|(pattern, _)| subject_matches(pattern, &subject))
198			.flat_map(|(_, txs)| txs.iter())
199			.next()
200			.cloned()
201			.ok_or_else(|| Error::SendFailed {
202				error: format!("no responder registered for subject '{subject}'"),
203			})?;
204
205		sender
206			.try_send((bytes, reply_tx))
207			.map_err(|error| Error::SendFailed { error: error.to_string() })?;
208
209		let reply_bytes = reply_rx.await.map_err(|_| Error::ReceptionFailed {
210			subject: subject.clone(),
211			error: "responder dropped without replying".to_owned(),
212		})?;
213
214		serde_json::from_slice(&reply_bytes)
215			.map_err(|error| Error::ReceptionFailed { subject, error: error.to_string() })
216	}
217}
218
219impl Responder for MockBus {
220	#[expect(clippy::expect_used, reason = "panicking is acceptable in test-only code")]
221	async fn respond<C: Command + 'static>(
222		self,
223	) -> Result<
224		impl Stream<Item = Result<impl ReceivedCommand<C> + Send + Sync + 'static, Error>>
225		+ Send
226		+ 'static,
227		Error,
228	> {
229		let (tx, rx) = mpsc::channel::<MockCommandPayload>(1024);
230		self.responders
231			.lock()
232			.expect("lock should not be poisoned: no holder should panic while the mutex is held")
233			.entry(C::SUBJECT.to_owned())
234			.or_default()
235			.push(tx);
236		Ok(rx.map(|(bytes, reply_tx)| {
237			let cmd = C::from_payload(C::SUBJECT, &bytes).map_err(|error| {
238				Error::ReceptionFailed { subject: C::SUBJECT.to_owned(), error: error.to_string() }
239			})?;
240			Ok(MockReceivedCommand::with_reply_channel(cmd, reply_tx))
241		}))
242	}
243}