Skip to main content

ruststream_rdkafka/testing/
broker.rs

1//! The in-process broker: core trait impls plus the `TestableBroker` registration.
2
3use std::fmt;
4use std::sync::{Arc, OnceLock};
5
6use bytes::Bytes;
7use ruststream::testing::{Coordinator, TestableBroker};
8use ruststream::{Broker, DescribeServer, OutgoingMessage, RawMessage, ServerSpec, Subscribe};
9
10use super::publisher::KafkaTestPublisher;
11use super::router::KeyRouter;
12use super::subscriber::KafkaTestSubscriber;
13use crate::error::KafkaError;
14
15pub(crate) struct TestBrokerState {
16    pub(crate) router: KeyRouter,
17    coordinator: OnceLock<Coordinator>,
18}
19
20impl TestBrokerState {
21    pub(crate) fn install(&self, coordinator: Coordinator) {
22        // A second install on the same broker is ignored on purpose: the trait demands
23        // idempotency.
24        let _ = self.coordinator.set(coordinator);
25    }
26
27    pub(crate) fn coordinator(&self) -> Option<Coordinator> {
28        self.coordinator.get().cloned()
29    }
30}
31
32impl Default for TestBrokerState {
33    fn default() -> Self {
34        Self {
35            router: KeyRouter::default(),
36            coordinator: OnceLock::new(),
37        }
38    }
39}
40
41impl fmt::Debug for TestBrokerState {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        f.debug_struct("TestBrokerState")
44            .field("router", &self.router)
45            .finish_non_exhaustive()
46    }
47}
48
49/// In-process broker for application tests: same descriptors, no Kafka cluster.
50///
51/// Clones share one router, so a publisher and a subscriber cloned from the same broker see
52/// each other; separate [`new`](Self::new) calls are fully isolated.
53///
54/// # Examples
55///
56/// ```
57/// use ruststream::{Broker, OutgoingMessage, Publisher, Subscriber};
58/// use ruststream_rdkafka::testing::KafkaTestBroker;
59/// # #[tokio::main(flavor = "current_thread")]
60/// # async fn main() -> Result<(), ruststream_rdkafka::KafkaError> {
61/// let broker = KafkaTestBroker::new();
62/// let mut subscriber = broker.subscribe("orders").await?;
63/// broker.publisher().publish(OutgoingMessage::new("orders", b"{}")).await?;
64/// # Ok(())
65/// # }
66/// ```
67#[derive(Debug, Clone, Default)]
68pub struct KafkaTestBroker {
69    state: Arc<TestBrokerState>,
70}
71
72impl KafkaTestBroker {
73    /// Creates an isolated in-process broker.
74    #[must_use]
75    pub fn new() -> Self {
76        Self::default()
77    }
78
79    /// Subscribes to `topic` (exact-name routing; no groups or partitions in-process).
80    ///
81    /// # Errors
82    ///
83    /// Returns [`KafkaError::InvalidOptions`] when `topic` is empty or a `^` pattern.
84    // Async without an await on purpose: call-site parity with the real broker, so application
85    // code and tests compile unchanged against either.
86    #[allow(clippy::unused_async)]
87    pub async fn subscribe(
88        &self,
89        topic: impl Into<String>,
90    ) -> Result<KafkaTestSubscriber, KafkaError> {
91        self.subscribe_topics(std::slice::from_ref(&topic.into()))
92            .await
93    }
94
95    /// Subscribes to several topics as one subscription, mirroring
96    /// [`KafkaTopic::and_topic`](crate::KafkaTopic::and_topic): every name routes exactly.
97    ///
98    /// # Errors
99    ///
100    /// Returns [`KafkaError::InvalidOptions`] when a name is empty, or when a name is a `^`
101    /// pattern: the in-process broker routes by exact topic name, so pattern subscriptions
102    /// need a real cluster.
103    // Async without an await on purpose: call-site parity with the real broker.
104    #[allow(clippy::unused_async)]
105    pub async fn subscribe_topics(
106        &self,
107        topics: &[String],
108    ) -> Result<KafkaTestSubscriber, KafkaError> {
109        for topic in topics {
110            if topic.is_empty() {
111                return Err(KafkaError::InvalidOptions(
112                    "topic name must not be empty; subscribe with the topic the handler \
113                     consumes"
114                        .to_owned(),
115                ));
116            }
117            if topic.starts_with('^') {
118                return Err(KafkaError::InvalidOptions(format!(
119                    "the in-process test broker routes by exact topic name; the pattern \
120                     {topic:?} needs a real cluster",
121                )));
122            }
123        }
124        Ok(KafkaTestSubscriber::open_many(&self.state, topics))
125    }
126
127    /// A publisher into this broker's router.
128    #[must_use]
129    pub fn publisher(&self) -> KafkaTestPublisher {
130        KafkaTestPublisher::new(Arc::clone(&self.state))
131    }
132}
133
134impl Broker for KafkaTestBroker {
135    type Error = KafkaError;
136
137    async fn connect(&self) -> Result<(), Self::Error> {
138        Ok(())
139    }
140
141    async fn shutdown(&self) -> Result<(), Self::Error> {
142        self.state.router.clear();
143        Ok(())
144    }
145}
146
147// `Self::subscribe` inside this impl would resolve to the trait method and recurse; the type
148// name is the only way to reach the inherent one.
149#[allow(clippy::use_self)]
150impl Subscribe for KafkaTestBroker {
151    type Subscriber = KafkaTestSubscriber;
152
153    async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
154        KafkaTestBroker::subscribe(self, name).await
155    }
156}
157
158impl DescribeServer for KafkaTestBroker {
159    fn describe_server(&self) -> ServerSpec {
160        ServerSpec::in_process("kafka")
161    }
162}
163
164// --8<-- [start:testable]
165impl TestableBroker for KafkaTestBroker {
166    fn install_coordinator(&self, coordinator: Coordinator) {
167        self.state.install(coordinator);
168    }
169
170    fn inject(&self, message: OutgoingMessage<'_>) {
171        self.state.router.publish(
172            message.name(),
173            &Bytes::copy_from_slice(message.payload()),
174            message.headers(),
175            self.state.coordinator().as_ref(),
176        );
177    }
178
179    fn published(&self, name: &str) -> Vec<RawMessage> {
180        self.state.router.published(name)
181    }
182}
183
184ruststream::register_testable_broker!(KafkaTestBroker);
185// --8<-- [end:testable]