Skip to main content

zebra_network/peer/client/
tests.rs

1//! Tests for the [`Client`] part of peer connections, and some test utilities for mocking
2//! [`Client`] instances.
3
4#![allow(clippy::unwrap_in_result)]
5#![cfg_attr(feature = "proptest-impl", allow(dead_code))]
6
7use std::{
8    net::{Ipv4Addr, SocketAddrV4},
9    sync::Arc,
10};
11
12use chrono::Utc;
13use futures::{
14    channel::{mpsc, oneshot},
15    future::{self, AbortHandle, Future, FutureExt},
16};
17use tokio::{
18    sync::broadcast::{self, error::TryRecvError},
19    task::JoinHandle,
20};
21
22use zebra_chain::block::Height;
23
24use crate::{
25    constants,
26    peer::{
27        error::SharedPeerError, CancelHeartbeatTask, Client, ClientRequest, ConnectedAddr,
28        ConnectionInfo, ErrorSlot,
29    },
30    peer_set::InventoryChange,
31    protocol::{
32        external::{types::Version, AddrInVersion},
33        types::{Nonce, PeerServices},
34    },
35    BoxError, VersionMessage,
36};
37
38#[cfg(test)]
39mod vectors;
40
41/// A harness with mocked channels for testing a [`Client`] instance.
42pub struct ClientTestHarness {
43    client_request_receiver: Option<mpsc::Receiver<ClientRequest>>,
44    shutdown_receiver: Option<oneshot::Receiver<CancelHeartbeatTask>>,
45    #[allow(dead_code)]
46    inv_receiver: Option<broadcast::Receiver<InventoryChange>>,
47    error_slot: ErrorSlot,
48    remote_version: Version,
49    connection_aborter: AbortHandle,
50    heartbeat_aborter: AbortHandle,
51}
52
53/// A client request using the same response and missing-inventory handling as a connection.
54pub struct ReceivedClientRequest(super::InProgressClientRequest);
55
56impl ReceivedClientRequest {
57    /// Returns the request sent through the real client channel.
58    pub fn request(&self) -> &crate::Request {
59        &self.0.request
60    }
61
62    /// Sends a response through the production response sender and inventory collector.
63    pub fn respond(self, response: Result<crate::Response, SharedPeerError>) {
64        let _ = self.0.tx.send(response);
65    }
66
67    /// Returns whether the caller dropped its response future.
68    pub fn is_canceled(&self) -> bool {
69        self.0.tx.is_canceled()
70    }
71}
72
73impl ClientTestHarness {
74    /// Create a [`ClientTestHarnessBuilder`] instance to help create a new [`Client`] instance
75    /// and a [`ClientTestHarness`] to track it.
76    pub fn build() -> ClientTestHarnessBuilder {
77        ClientTestHarnessBuilder {
78            version: None,
79            services: None,
80            start_height: None,
81            connection_task: None,
82            heartbeat_task: None,
83            connected_addr: None,
84        }
85    }
86
87    /// Gets the remote peer protocol version reported by the [`Client`].
88    pub fn remote_version(&self) -> Version {
89        self.remote_version
90    }
91
92    /// Returns true if the [`Client`] instance still wants connection heartbeats to be sent.
93    ///
94    /// Checks that the client:
95    /// - has not been dropped,
96    /// - has not closed or dropped the mocked heartbeat task channel, and
97    /// - has not asked the mocked heartbeat task to shut down.
98    pub fn wants_connection_heartbeats(&mut self) -> bool {
99        let receive_result = self
100            .shutdown_receiver
101            .as_mut()
102            .expect("heartbeat shutdown receiver endpoint has been dropped")
103            .try_recv();
104
105        match receive_result {
106            Ok(None) => true,
107            Ok(Some(CancelHeartbeatTask)) | Err(oneshot::Canceled) => false,
108        }
109    }
110
111    /// Drops the mocked heartbeat shutdown receiver endpoint.
112    pub fn drop_heartbeat_shutdown_receiver(&mut self) {
113        let hearbeat_future = self
114            .shutdown_receiver
115            .take()
116            .expect("unexpected test failure: heartbeat shutdown receiver endpoint has already been dropped");
117
118        std::mem::drop(hearbeat_future);
119    }
120
121    /// Closes the receiver endpoint of [`ClientRequest`]s that are supposed to be sent to the
122    /// remote peer.
123    ///
124    /// The remote peer that would receive the requests is mocked for testing.
125    pub fn close_outbound_client_request_receiver(&mut self) {
126        self.client_request_receiver
127            .as_mut()
128            .expect("request receiver endpoint has been dropped")
129            .close();
130    }
131
132    /// Drops the receiver endpoint of [`ClientRequest`]s, forcefully closing the channel.
133    ///
134    /// The remote peer that would receive the requests is mocked for testing.
135    pub fn drop_outbound_client_request_receiver(&mut self) {
136        self.client_request_receiver
137            .take()
138            .expect("request receiver endpoint has already been dropped");
139    }
140
141    /// Receives a request with production response handling for cross-crate consumer tests.
142    pub fn take_request(&mut self) -> Option<ReceivedClientRequest> {
143        self.try_to_receive_outbound_client_request()
144            .request()
145            .map(|request| ReceivedClientRequest(request.into()))
146    }
147
148    /// Tries to receive a [`ClientRequest`] sent by the [`Client`] instance.
149    ///
150    /// The remote peer that would receive the requests is mocked for testing.
151    pub(crate) fn try_to_receive_outbound_client_request(&mut self) -> ReceiveRequestAttempt {
152        let receive_result = self
153            .client_request_receiver
154            .as_mut()
155            .expect("request receiver endpoint has been dropped")
156            .try_recv();
157
158        match receive_result {
159            Ok(request) => ReceiveRequestAttempt::Request(request),
160            Err(mpsc::TryRecvError::Closed) => ReceiveRequestAttempt::Closed,
161            Err(mpsc::TryRecvError::Empty) => ReceiveRequestAttempt::Empty,
162        }
163    }
164
165    /// Drops the receiver endpoint of [`InventoryChange`]s, forcefully closing the channel.
166    ///
167    /// The inventory registry that would track the changes is mocked for testing.
168    ///
169    /// Note: this closes the broadcast receiver, it doesn't have a separate `close()` method.
170    #[allow(dead_code)]
171    pub fn drop_inventory_change_receiver(&mut self) {
172        self.inv_receiver
173            .take()
174            .expect("inventory change receiver endpoint has already been dropped");
175    }
176
177    /// Tries to receive an [`InventoryChange`] sent by the [`Client`] instance.
178    ///
179    /// This method acts like a mock inventory registry, allowing tests to track the changes.
180    ///
181    /// TODO: make ReceiveRequestAttempt generic, and use it here.
182    #[allow(dead_code)]
183    #[allow(clippy::unwrap_in_result)]
184    pub(crate) fn try_to_receive_inventory_change(&mut self) -> Option<InventoryChange> {
185        let receive_result = self
186            .inv_receiver
187            .as_mut()
188            .expect("inventory change receiver endpoint has been dropped")
189            .try_recv();
190
191        match receive_result {
192            Ok(change) => Some(change),
193            Err(TryRecvError::Empty) => None,
194            Err(TryRecvError::Closed) => None,
195            Err(TryRecvError::Lagged(skipped_messages)) => unreachable!(
196                "unexpected lagged inventory receiver in tests, skipped {} messages",
197                skipped_messages,
198            ),
199        }
200    }
201
202    /// Returns the current error in the [`ErrorSlot`], if there is one.
203    pub fn current_error(&self) -> Option<SharedPeerError> {
204        self.error_slot.try_get_error()
205    }
206
207    /// Sets the error in the [`ErrorSlot`], assuming there isn't one already.
208    ///
209    /// # Panics
210    ///
211    /// If there's already an error in the [`ErrorSlot`].
212    pub fn set_error(&self, error: impl Into<SharedPeerError>) {
213        self.error_slot
214            .try_update_error(error.into())
215            .expect("unexpected earlier error in error slot")
216    }
217
218    /// Stops the mock background task that handles incoming remote requests and replies.
219    pub async fn stop_connection_task(&self) {
220        self.connection_aborter.abort();
221
222        // Allow the task to detect that it was aborted.
223        tokio::task::yield_now().await;
224    }
225
226    /// Stops the mock background task that sends periodic heartbeats.
227    pub async fn stop_heartbeat_task(&self) {
228        self.heartbeat_aborter.abort();
229
230        // Allow the task to detect that it was aborted.
231        tokio::task::yield_now().await;
232    }
233}
234
235/// The result of an attempt to receive a [`ClientRequest`] sent by the [`Client`] instance.
236///
237/// The remote peer that would receive the request is mocked for testing.
238// The size disparity between the empty `Closed`/`Empty` variants and the
239// request-carrying variant is intrinsic to this test helper, which is only
240// constructed once per receive attempt.
241#[allow(clippy::large_enum_variant)]
242pub(crate) enum ReceiveRequestAttempt {
243    /// The [`Client`] instance has closed the sender endpoint of the channel.
244    Closed,
245
246    /// There were no queued requests in the channel.
247    Empty,
248
249    /// One request was successfully received.
250    Request(ClientRequest),
251}
252
253impl ReceiveRequestAttempt {
254    /// Check if the attempt to receive resulted in discovering that the sender endpoint had been
255    /// closed.
256    pub fn is_closed(&self) -> bool {
257        matches!(self, ReceiveRequestAttempt::Closed)
258    }
259
260    /// Check if the attempt to receive resulted in no requests.
261    pub fn is_empty(&self) -> bool {
262        matches!(self, ReceiveRequestAttempt::Empty)
263    }
264
265    /// Returns the received request, if there was one.
266    #[allow(dead_code)]
267    pub fn request(self) -> Option<ClientRequest> {
268        match self {
269            ReceiveRequestAttempt::Request(request) => Some(request),
270            ReceiveRequestAttempt::Closed | ReceiveRequestAttempt::Empty => None,
271        }
272    }
273}
274
275/// A builder for a [`Client`] and [`ClientTestHarness`] instance.
276///
277/// Mocked data is used to construct a real [`Client`] instance. The mocked data is initialized by
278/// the [`ClientTestHarnessBuilder`], and can be accessed and changed through the
279/// [`ClientTestHarness`].
280pub struct ClientTestHarnessBuilder<C = future::Ready<()>, H = future::Ready<()>> {
281    connection_task: Option<C>,
282    heartbeat_task: Option<H>,
283    version: Option<Version>,
284    services: Option<PeerServices>,
285    connected_addr: Option<ConnectedAddr>,
286    start_height: Option<Height>,
287}
288
289impl<C, H> ClientTestHarnessBuilder<C, H>
290where
291    C: Future<Output = ()> + Send + 'static,
292    H: Future<Output = ()> + Send + 'static,
293{
294    /// Configure the mocked version for the peer.
295    pub fn with_version(mut self, version: Version) -> Self {
296        self.version = Some(version);
297        self
298    }
299
300    /// Configure the mocked services the peer advertised in its `version` message.
301    pub fn with_services(mut self, services: PeerServices) -> Self {
302        self.services = Some(services);
303        self
304    }
305
306    /// Configure the mocked connection address metadata for the peer.
307    pub fn with_connected_addr(mut self, connected_addr: ConnectedAddr) -> Self {
308        self.connected_addr = Some(connected_addr);
309        self
310    }
311
312    /// Configure the peer's advertised handshake height.
313    pub fn with_start_height(mut self, start_height: Height) -> Self {
314        self.start_height = Some(start_height);
315        self
316    }
317
318    /// Configure the mock connection task future to use.
319    pub fn with_connection_task<NewC>(
320        self,
321        connection_task: NewC,
322    ) -> ClientTestHarnessBuilder<NewC, H> {
323        ClientTestHarnessBuilder {
324            connection_task: Some(connection_task),
325            heartbeat_task: self.heartbeat_task,
326            version: self.version,
327            services: self.services,
328            connected_addr: self.connected_addr,
329            start_height: self.start_height,
330        }
331    }
332
333    /// Configure the mock heartbeat task future to use.
334    pub fn with_heartbeat_task<NewH>(
335        self,
336        heartbeat_task: NewH,
337    ) -> ClientTestHarnessBuilder<C, NewH> {
338        ClientTestHarnessBuilder {
339            connection_task: self.connection_task,
340            heartbeat_task: Some(heartbeat_task),
341            version: self.version,
342            services: self.services,
343            connected_addr: self.connected_addr,
344            start_height: self.start_height,
345        }
346    }
347
348    /// Build a [`Client`] instance with the mocked data and a [`ClientTestHarness`] to track it.
349    pub fn finish(self) -> (Client, ClientTestHarness) {
350        let (shutdown_sender, shutdown_receiver) = oneshot::channel();
351        let (client_request_sender, client_request_receiver) = mpsc::channel(1);
352        let (inv_sender, inv_receiver) = broadcast::channel(5);
353
354        let error_slot = ErrorSlot::default();
355        let remote_version = self.version.unwrap_or(Version(0));
356
357        let (connection_task, connection_aborter) =
358            Self::spawn_background_task_or_fallback(self.connection_task);
359        let (heartbeat_task, heartbeat_aborter) =
360            Self::spawn_background_task_or_fallback_with_result(self.heartbeat_task);
361
362        let negotiated_version =
363            std::cmp::min(remote_version, constants::CURRENT_NETWORK_PROTOCOL_VERSION);
364
365        let remote = VersionMessage {
366            version: remote_version,
367            services: self.services.unwrap_or_default(),
368            timestamp: Utc::now(),
369            address_recv: AddrInVersion::new(
370                SocketAddrV4::new(Ipv4Addr::LOCALHOST, 1),
371                PeerServices::default(),
372            ),
373            address_from: AddrInVersion::new(
374                SocketAddrV4::new(Ipv4Addr::LOCALHOST, 2),
375                PeerServices::default(),
376            ),
377            nonce: Nonce::default(),
378            user_agent: "client test harness".to_string(),
379            start_height: self.start_height.unwrap_or(Height(0)),
380            relay: true,
381        };
382
383        let connection_info = Arc::new(ConnectionInfo {
384            connected_addr: self.connected_addr.unwrap_or(ConnectedAddr::Isolated),
385            remote,
386            negotiated_version,
387        });
388
389        let client = Client {
390            connection_info,
391            shutdown_tx: Some(shutdown_sender),
392            server_tx: client_request_sender,
393            inv_collector: inv_sender,
394            error_slot: error_slot.clone(),
395            connection_task,
396            heartbeat_task,
397        };
398
399        let harness = ClientTestHarness {
400            client_request_receiver: Some(client_request_receiver),
401            shutdown_receiver: Some(shutdown_receiver),
402            inv_receiver: Some(inv_receiver),
403            error_slot,
404            remote_version,
405            connection_aborter,
406            heartbeat_aborter,
407        };
408
409        (client, harness)
410    }
411
412    /// Spawn a mock background abortable task `task_future` if provided, or a fallback task
413    /// otherwise.
414    ///
415    /// The fallback task stays alive until explicitly aborted.
416    fn spawn_background_task_or_fallback<T>(task_future: Option<T>) -> (JoinHandle<()>, AbortHandle)
417    where
418        T: Future<Output = ()> + Send + 'static,
419    {
420        match task_future {
421            Some(future) => Self::spawn_background_task(future),
422            None => Self::spawn_background_task(future::pending()),
423        }
424    }
425
426    /// Spawn a mock background abortable task to run `task_future`.
427    fn spawn_background_task<T>(task_future: T) -> (JoinHandle<()>, AbortHandle)
428    where
429        T: Future<Output = ()> + Send + 'static,
430    {
431        let (task, abort_handle) = future::abortable(task_future);
432        let task_handle = tokio::spawn(task.map(|_result| ()));
433
434        (task_handle, abort_handle)
435    }
436
437    // TODO: In the context of #4734:
438    // - Delete `spawn_background_task_or_fallback` and `spawn_background_task`
439    // - Rename `spawn_background_task_or_fallback_with_result` and `spawn_background_task_with_result` to
440    //   `spawn_background_task_or_fallback` and `spawn_background_task`
441
442    // Similar to `spawn_background_task_or_fallback` but returns a `Result`.
443    fn spawn_background_task_or_fallback_with_result<T>(
444        task_future: Option<T>,
445    ) -> (JoinHandle<Result<(), BoxError>>, AbortHandle)
446    where
447        T: Future<Output = ()> + Send + 'static,
448    {
449        match task_future {
450            Some(future) => Self::spawn_background_task_with_result(future),
451            None => Self::spawn_background_task_with_result(future::pending()),
452        }
453    }
454
455    // Similar to `spawn_background_task` but returns a `Result`.
456    fn spawn_background_task_with_result<T>(
457        task_future: T,
458    ) -> (JoinHandle<Result<(), BoxError>>, AbortHandle)
459    where
460        T: Future<Output = ()> + Send + 'static,
461    {
462        let (task, abort_handle) = future::abortable(task_future);
463        let task_handle = tokio::spawn(task.map(|_result| Ok(())));
464
465        (task_handle, abort_handle)
466    }
467}