Skip to main content

ruststream_lapin/testing/
broker.rs

1//! The in-process ladder: [`LapinTestBroker`] -> [`ConnectedLapinTestBroker`].
2
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::{Arc, OnceLock};
5
6use bytes::Bytes;
7use ruststream::testing::{Coordinator, TestableBroker};
8use ruststream::{
9    Broker, ConnectedBroker, DefaultPublish, DescribeServer, OutgoingMessage, RawMessage,
10    ServerSpec, Subscribe,
11};
12
13use super::publisher::{LapinTestPublish, LapinTestPublisher};
14use super::router::KeyRouter;
15use super::subscriber::LapinTestSubscriber;
16use crate::error::AmqpError;
17
18/// Shared state owned by every handle on a single test broker instance.
19///
20/// The unconnected broker, its connected form, and every publisher paired off it share one
21/// [`Arc`] of this, so they all see the same router. Distinct instances (different
22/// [`LapinTestBroker::new`] calls) are fully isolated.
23pub(crate) struct TestBrokerState {
24    pub(crate) router: KeyRouter,
25    /// Mirrors the real broker's post-shutdown behaviour: handles aliasing a shut-down transport
26    /// must report an error rather than route into a dead router.
27    closed: AtomicBool,
28    coordinator: OnceLock<Coordinator>,
29}
30
31impl TestBrokerState {
32    pub(crate) fn install(&self, coordinator: Coordinator) {
33        // A second install on the same broker is ignored on purpose: the trait demands
34        // idempotency.
35        let _ = self.coordinator.set(coordinator);
36    }
37
38    pub(crate) fn coordinator(&self) -> Option<Coordinator> {
39        self.coordinator.get().cloned()
40    }
41
42    /// `Ok` while the transport is live, [`AmqpError::Closed`] once it has shut down.
43    pub(crate) fn ensure_live(&self, target: &str) -> Result<(), AmqpError> {
44        if self.closed.load(Ordering::Acquire) {
45            return Err(AmqpError::closed(target));
46        }
47        Ok(())
48    }
49}
50
51impl Default for TestBrokerState {
52    fn default() -> Self {
53        Self {
54            router: KeyRouter::default(),
55            closed: AtomicBool::new(false),
56            coordinator: OnceLock::new(),
57        }
58    }
59}
60
61impl std::fmt::Debug for TestBrokerState {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct("TestBrokerState")
64            .field("router", &self.router)
65            .field("closed", &self.closed.load(Ordering::Relaxed))
66            .finish_non_exhaustive()
67    }
68}
69
70/// In-process broker for application tests: same descriptors, no `RabbitMQ` server.
71///
72/// Mirrors the real ladder: `new` is synchronous, and the consuming `connect` hands out the
73/// [`ConnectedLapinTestBroker`] that carries the subscribe and publish surface.
74///
75/// # Examples
76///
77/// ```
78/// use ruststream::{Broker, OutgoingMessage, Publisher, Subscriber};
79/// use ruststream_lapin::testing::{LapinTestBroker, LapinTestPublish};
80/// # #[tokio::main(flavor = "current_thread")]
81/// # async fn main() -> Result<(), ruststream_lapin::AmqpError> {
82/// let broker = LapinTestBroker::new().connect().await?;
83/// let mut subscriber = broker.subscribe("orders").await?;
84/// broker
85///     .publisher(LapinTestPublish)
86///     .publish(OutgoingMessage::new("orders", b"{}"))
87///     .await?;
88/// # Ok(())
89/// # }
90/// ```
91#[derive(Debug, Clone, Default)]
92#[must_use]
93pub struct LapinTestBroker {
94    state: Arc<TestBrokerState>,
95}
96
97impl LapinTestBroker {
98    /// Creates an isolated in-process broker.
99    pub fn new() -> Self {
100        Self::default()
101    }
102}
103
104impl Broker for LapinTestBroker {
105    type Error = AmqpError;
106    type Connected = ConnectedLapinTestBroker;
107
108    async fn connect(self) -> Result<Self::Connected, Self::Error> {
109        Ok(ConnectedLapinTestBroker { state: self.state })
110    }
111}
112
113impl DescribeServer for LapinTestBroker {
114    fn describe_server(&self) -> ServerSpec {
115        ServerSpec::in_process("amqp")
116    }
117}
118
119/// The connected form of [`LapinTestBroker`].
120///
121/// Routes published messages to subscribers by exact queue name (the default-exchange model) and
122/// implements [`TestableBroker`], so it drives both the
123/// [`TestApp`](ruststream::testing::TestApp) harness and the framework's conformance suite in
124/// process. Clones share one router, so a publisher and a subscriber taken from the same broker
125/// see each other.
126#[derive(Debug, Clone)]
127pub struct ConnectedLapinTestBroker {
128    state: Arc<TestBrokerState>,
129}
130
131impl ConnectedLapinTestBroker {
132    pub(crate) fn state(&self) -> Arc<TestBrokerState> {
133        Arc::clone(&self.state)
134    }
135
136    /// Subscribes to `queue` (exact-name routing, the default-exchange model).
137    ///
138    /// # Errors
139    ///
140    /// Returns [`AmqpError::InvalidOptions`] when `queue` is empty and [`AmqpError::Closed`]
141    /// once the transport has shut down.
142    // Async without an await on purpose: call-site parity with the real broker, so application
143    // code and tests compile unchanged against either.
144    #[allow(clippy::unused_async)]
145    pub async fn subscribe(
146        &self,
147        queue: impl Into<String>,
148    ) -> Result<LapinTestSubscriber, AmqpError> {
149        let queue = queue.into();
150        if queue.is_empty() {
151            return Err(AmqpError::InvalidOptions(
152                "queue name must not be empty; subscribe with the queue the handler consumes"
153                    .to_owned(),
154            ));
155        }
156        self.state.ensure_live(&queue)?;
157        Ok(LapinTestSubscriber::open(&self.state, queue))
158    }
159
160    /// A live publisher into this broker's router, mirroring
161    /// [`ConnectedLapinBroker::publisher`](crate::ConnectedLapinBroker::publisher). The
162    /// in-process transport routes by queue name only, so it has a single policy.
163    #[must_use]
164    pub fn publisher(&self, policy: LapinTestPublish) -> LapinTestPublisher {
165        policy.bind(self)
166    }
167}
168
169impl ConnectedBroker for ConnectedLapinTestBroker {
170    type Error = AmqpError;
171    type Closed = ();
172
173    async fn shutdown(self) -> Result<Self::Closed, Self::Error> {
174        self.state.closed.store(true, Ordering::Release);
175        self.state.router.clear();
176        Ok(())
177    }
178}
179
180// `Self::subscribe` inside this impl would resolve to the trait method and recurse; the type
181// name is the only way to reach the inherent one.
182#[allow(clippy::use_self)]
183impl Subscribe for ConnectedLapinTestBroker {
184    type Subscriber = LapinTestSubscriber;
185
186    async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
187        ConnectedLapinTestBroker::subscribe(self, name).await
188    }
189}
190
191impl DefaultPublish for ConnectedLapinTestBroker {
192    type Policy = LapinTestPublish;
193}
194
195// --8<-- [start:testable]
196impl TestableBroker for ConnectedLapinTestBroker {
197    fn install_coordinator(&self, coordinator: Coordinator) {
198        self.state.install(coordinator);
199    }
200
201    fn inject(&self, message: OutgoingMessage<'_>) {
202        self.state.router.publish(
203            message.name(),
204            &Bytes::copy_from_slice(message.payload()),
205            message.headers(),
206            self.state.coordinator().as_ref(),
207        );
208    }
209
210    fn published(&self, name: &str) -> Vec<RawMessage> {
211        self.state.router.published(name)
212    }
213}
214
215ruststream::register_testable_broker!(ConnectedLapinTestBroker);
216// --8<-- [end:testable]