arminius_lib_message_bus/mock/
mod.rs1mod 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
28type MockPublishedMessages = Vec<(String, Vec<u8>)>;
30type MockSenderHashMap = HashMap<String, Vec<mpsc::Sender<Vec<u8>>>>;
32type MockCommandPayload = (Vec<u8>, oneshot::Sender<Vec<u8>>);
35type MockResponderHashMap = HashMap<String, Vec<mpsc::Sender<MockCommandPayload>>>;
38
39#[derive(Clone, Debug, Default)]
81pub struct MockBus {
82 published: Arc<Mutex<MockPublishedMessages>>,
84 senders: Arc<Mutex<MockSenderHashMap>>,
86 responders: Arc<Mutex<MockResponderHashMap>>,
88}
89
90impl MockBus {
91 #[must_use]
93 pub fn new() -> Self {
94 Self::default()
95 }
96
97 #[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 let subject = message.subject().into_owned();
120
121 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 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}