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