omry_integration/
helpers.rs

1//! Helper functions and data structures for integration tests.
2use crate::db::TempDb;
3use omry_archiving::{Document, Marshal, Record, RecordParams, ToRecord};
4use std::env;
5use std::fs::{self, File};
6use std::io::prelude::*;
7use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6, TcpListener, ToSocketAddrs};
8use std::path::{Path, PathBuf};
9use tempfile::tempdir;
10use tracing::Level;
11use tracing_subscriber::FmtSubscriber;
12
13const WORKSPACE_DIR_KEY: &str = "CARGO_WORKSPACE_DIR";
14const TEST_ARCHIVE_SUBPATH: &str = "test/data/example.bincode";
15const FIND_PORT_MAX_RETRIES: u8 = 5;
16
17/// Gets a free port for the given socket from the OS. Returns the listener and the port.
18///
19/// The listener is returned to deal with TOCTOU, so that the OS
20/// doesn't give the port to another service before we bind to it for real
21/// wherever we actually need that port in our tests.
22fn free_port_from_socket_addr(
23    socket: impl ToSocketAddrs,
24    max_attempts: u8,
25) -> anyhow::Result<(TcpListener, u16)> {
26    let mut attempts_left = max_attempts;
27    let socket_addrs = socket.to_socket_addrs()?.collect::<Vec<_>>();
28
29    while attempts_left > 0 {
30        if let Ok(listener) = TcpListener::bind(socket_addrs.as_slice()) {
31            let port = listener.local_addr()?.port();
32            return Ok((listener, port));
33        }
34        attempts_left -= 1;
35    }
36
37    anyhow::bail!("Couldn't bind a listener to a port in {max_attempts} attempts.");
38}
39
40/// Gets a free IPv4 port from the OS. Returns the listener and the port.
41///
42/// The listener is returned to account for TOCTOU, so that the OS
43/// doesn't release the port for another service before we bind to it for real
44/// in our tests.
45///
46/// ## Panics
47/// If the port can't be found within some number of retries (currently 3).
48#[must_use]
49pub fn free_port_ipv4() -> (TcpListener, u16) {
50    let socket = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0);
51
52    // Okay to panic if we can't find a free port, since we can't run integration tests
53    // without a port anyway.
54    #[allow(clippy::expect_used)]
55    free_port_from_socket_addr(socket, FIND_PORT_MAX_RETRIES).expect("Couldn't find a free port.")
56}
57
58/// Gets a free IPv6 port from the OS. See [`free_port_ipv4`] for details about the return value.
59///
60/// ## Panics
61/// If the port can't be found within some number of retries (currently 3).
62#[must_use]
63pub fn free_port_ipv6() -> (TcpListener, u16) {
64    let socket = SocketAddrV6::new(Ipv6Addr::LOCALHOST, 0, 0, 0);
65
66    // Okay to panic if we can't find a free port, since we can't run integration tests
67    // without a port anyway.
68    #[allow(clippy::expect_used)]
69    free_port_from_socket_addr(socket, FIND_PORT_MAX_RETRIES).expect("Couldn't find a free port.")
70}
71
72fn make_test_archive_holder() -> anyhow::Result<Document> {
73    let (_, workspace_dir_path) = env::vars()
74        .find(|(k, _)| k == WORKSPACE_DIR_KEY)
75        .ok_or_else(|| anyhow::anyhow!("{WORKSPACE_DIR_KEY} is not set"))?;
76
77    let workspace_dir_path = fs::canonicalize(workspace_dir_path)?;
78    let test_archive_path = workspace_dir_path.join(TEST_ARCHIVE_SUBPATH);
79
80    let mut file = File::open(test_archive_path)?;
81    let mut buf = vec![];
82    file.read_to_end(&mut buf)?;
83
84    let archive_holder = Document::from_bytes(&buf)?;
85    Ok(archive_holder)
86}
87
88/// Makes an instance of [`RecordParams`] for tests.
89///
90/// ## Errors
91/// Returns [`anyhow::Error`] if the test document can't be loaded.
92pub fn make_test_record_params() -> anyhow::Result<RecordParams> {
93    let document = make_test_archive_holder()?;
94    let url = "https://example.org/".to_string();
95    let title = "Example".to_string();
96    let client_datetime = "2024-10-11T13:49:46-05:00".to_string();
97    let language = Some("en".to_string());
98
99    Ok(RecordParams {
100        id: None,
101        url,
102        title,
103        language,
104        client_datetime,
105        document,
106        timestamp_flora: None,
107    })
108}
109
110/// Makes a [`Record`] instance for tests.
111///
112/// ## Errors
113/// Returns [`anyhow::Error`] if the test record can't be loaded.
114pub fn make_test_record() -> anyhow::Result<Record> {
115    let incoming_record = Record::new(make_test_record_params()?);
116    Ok(incoming_record)
117}
118
119/// Gets records that have been inserted into the in-memory SQLite,
120/// so that they have unique ids.
121///
122/// ## Errors
123/// Returns [`anyhow::Error`] if any of the following operations fail:
124///
125/// - We can't create a new [`TempDb`].
126/// - We can't insert the test record into SQLite.
127/// - We can't get that record back from SQLite.
128pub async fn make_test_db_records(how_many: usize) -> anyhow::Result<Vec<impl ToRecord>> {
129    let db = TempDb::new().await?;
130    let mut db_records = Vec::with_capacity(how_many);
131    for _ in 0..how_many {
132        let record = make_test_record()?;
133        let row_id = db.insert(record.try_into()?).await?;
134        let db_record = db.get_record(row_id).await?;
135        db_records.push(db_record);
136    }
137    Ok(db_records)
138}
139
140/// Temporary path and its string representation.
141#[derive(Debug)]
142pub struct TempPathWithString {
143    path: PathBuf,
144    path_string: String,
145}
146
147impl TempPathWithString {
148    /// Creates a new [`tempfile::TempDir`], and stores its associated path
149    /// and string representation.
150    ///
151    /// ## Errors
152    /// If we can't create a temporary directory or construct the string representation
153    /// of its path.
154    pub fn new() -> anyhow::Result<Self> {
155        let temp_path = tempdir()?.keep();
156
157        // If we can't get a string representation of the temp path, then its display
158        // repesentation is unlikely to be useful.
159        // So clippy's suggestion isn't helpful here.
160        #[allow(clippy::unnecessary_debug_formatting)]
161        let temp_path_string = temp_path
162            .to_str()
163            .ok_or_else(|| anyhow::anyhow!("Couldn't get a path string from {temp_path:?}"))?
164            .to_string();
165
166        Ok(Self {
167            path: temp_path,
168            path_string: temp_path_string,
169        })
170    }
171
172    /// Returns a reference to this temporary directory's path.
173    #[must_use]
174    pub fn as_path(&self) -> &Path {
175        &self.path
176    }
177
178    /// Returns a reference to this temporary path's string representation.
179    #[must_use]
180    pub fn as_str(&self) -> &str {
181        &self.path_string
182    }
183}
184
185/// Initializes a tracing subscriber for tests.
186///
187/// ## Errors
188/// Returns [`anyhow::Error`] if
189/// we can't set the tracing subscriber as the global default.
190pub fn init_tracing_subscriber() -> anyhow::Result<()> {
191    let subscriber = FmtSubscriber::builder()
192        .with_max_level(Level::INFO)
193        .finish();
194    tracing::subscriber::set_global_default(subscriber)?;
195
196    Ok(())
197}