omry_integration/
helpers.rs

1//! Helper functions and data structures for integration tests.
2use crate::db::TempDb;
3use anyhow::Result;
4use omry_archiving::{Document, Marshal, Record, RecordParams, ToRecord};
5use std::env;
6use std::fs::{self, File};
7use std::io::prelude::*;
8use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6, TcpListener, ToSocketAddrs};
9use std::path::{Path, PathBuf};
10use tempfile::{TempDir, tempdir};
11use tracing::Level;
12use tracing_subscriber::FmtSubscriber;
13use uuid::Uuid;
14
15/// Environment variable key that corresponds to the Cargo workspace directory.
16const WORKSPACE_DIR_KEY: &str = "CARGO_WORKSPACE_DIR";
17
18/// The path to the serialized web page used in tests.
19const TEST_ARCHIVE_SUBPATH: &str = "test/data/example.bincode";
20
21/// Maximum number of times we'll try to find a free port to bind to.
22const FIND_PORT_MAX_RETRIES: u8 = 5;
23
24/// Gets a free port for the given socket from the OS. Returns the listener and the port.
25///
26/// The listener is returned to deal with TOCTOU, so that the OS
27/// doesn't give the port to another service before we bind to it for real
28/// wherever we actually need that port in our tests.
29fn free_port_from_socket_addr(
30    socket: impl ToSocketAddrs,
31    max_attempts: u8,
32) -> anyhow::Result<(TcpListener, u16)> {
33    let mut attempts_left = max_attempts;
34    let socket_addrs = socket.to_socket_addrs()?.collect::<Vec<_>>();
35
36    while attempts_left > 0 {
37        if let Ok(listener) = TcpListener::bind(socket_addrs.as_slice()) {
38            let port = listener.local_addr()?.port();
39            return Ok((listener, port));
40        }
41        attempts_left -= 1;
42    }
43
44    anyhow::bail!("Couldn't bind a listener to a port in {max_attempts} attempts.");
45}
46
47/// Gets a free IPv4 port from the OS. Returns the listener and the port.
48///
49/// The listener is returned to account for TOCTOU, so that the OS
50/// doesn't release the port for another service before we bind to it for real
51/// in our tests.
52///
53/// ## Panics
54/// If the port can't be found within some number of retries (currently 3).
55#[must_use]
56pub fn free_port_ipv4() -> (TcpListener, u16) {
57    let socket = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0);
58
59    // Okay to panic if we can't find a free port, since we can't run integration tests
60    // without a port anyway.
61    #[allow(clippy::expect_used)]
62    free_port_from_socket_addr(socket, FIND_PORT_MAX_RETRIES).expect("Couldn't find a free port.")
63}
64
65/// Gets a free IPv6 port from the OS. See [`free_port_ipv4`] for details about the return value.
66///
67/// ## Panics
68/// If the port can't be found within some number of retries (currently 3).
69#[must_use]
70pub fn free_port_ipv6() -> (TcpListener, u16) {
71    let socket = SocketAddrV6::new(Ipv6Addr::LOCALHOST, 0, 0, 0);
72
73    // Okay to panic if we can't find a free port, since we can't run integration tests
74    // without a port anyway.
75    #[allow(clippy::expect_used)]
76    free_port_from_socket_addr(socket, FIND_PORT_MAX_RETRIES).expect("Couldn't find a free port.")
77}
78
79/// Takes the given subpath, and returns the canonical path within
80/// the current Cargo project.
81///
82/// ## Errors
83/// Returns [`anyhow::Error`] if we fail to:
84///
85/// - Get the path to the current project via `CARGO_WORKSPACE_DIR` env variable
86/// - Get the full canonical path from it
87pub fn path_in_project(subpath: &str) -> Result<PathBuf> {
88    let (_, workspace_dir_path) = env::vars()
89        .find(|(k, _)| k == WORKSPACE_DIR_KEY)
90        .ok_or_else(|| anyhow::anyhow!("{WORKSPACE_DIR_KEY} is not set"))?;
91
92    let workspace_dir_path = fs::canonicalize(workspace_dir_path)?;
93    Ok(workspace_dir_path.join(subpath))
94}
95
96/// Loads a document to be used in tests.
97///
98/// ## Errors
99/// Returns [`anyhow::Error`] if we fail to:
100///
101/// - Get the path to the current project via `CARGO_WORKSPACE_DIR` env variable
102/// - Get the full canonical path from it
103/// - Read the file from that path
104/// - Deserialize the data from that file
105pub fn make_test_archive_holder() -> anyhow::Result<Document> {
106    let test_archive_path = path_in_project(TEST_ARCHIVE_SUBPATH)?;
107    let mut file = File::open(test_archive_path)?;
108    let mut buf = vec![];
109    file.read_to_end(&mut buf)?;
110    let archive_holder = Document::from_bytes(&buf)?;
111    Ok(archive_holder)
112}
113
114/// Makes an instance of [`RecordParams`] for tests.
115///
116/// ## Errors
117/// Returns [`anyhow::Error`] if the test document can't be loaded.
118pub fn make_test_record_params() -> anyhow::Result<RecordParams> {
119    let document = make_test_archive_holder()?;
120    let url = "https://example.org/".to_string();
121    let title = "Example".to_string();
122    let client_datetime = "2024-10-11T13:49:46-05:00".to_string();
123    let language = Some("en".to_string());
124
125    Ok(RecordParams {
126        id: None,
127        url,
128        title,
129        language,
130        client_datetime,
131        document,
132        timestamp_flora: None,
133    })
134}
135
136/// Makes a [`Record`] instance for tests.
137///
138/// ## Errors
139/// Returns [`anyhow::Error`] if the test record can't be loaded.
140pub fn make_test_record() -> anyhow::Result<Record> {
141    let incoming_record = Record::new(make_test_record_params()?);
142    Ok(incoming_record)
143}
144
145/// Gets records that have been inserted into the in-memory SQLite,
146/// so that they have unique ids.
147///
148/// ## Errors
149/// Returns [`anyhow::Error`] if any of the following operations fail:
150///
151/// - We can't create a new [`TempDb`].
152/// - We can't insert the test record into SQLite.
153/// - We can't get that record back from SQLite.
154pub async fn make_test_db_records(how_many: usize) -> anyhow::Result<Vec<impl ToRecord>> {
155    let db = TempDb::new().await?;
156    let mut db_records = Vec::with_capacity(how_many);
157    for _ in 0..how_many {
158        let record = make_test_record()?;
159        let row_id = db.insert(record.try_into()?).await?;
160        let db_record = db.get_record(row_id).await?;
161        db_records.push(db_record);
162    }
163    Ok(db_records)
164}
165
166/// Temporary path and its string representation.
167#[derive(Debug)]
168pub struct TempPathWithString {
169    /// The path.
170    path: PathBuf,
171
172    /// The string representation of the same path, to make it easier to use
173    /// in some functions.
174    path_string: String,
175}
176
177impl TempPathWithString {
178    /// Creates a new [`tempfile::TempDir`], and stores its associated path
179    /// and string representation.
180    ///
181    /// ## Errors
182    /// If we can't create a temporary directory or construct the string representation
183    /// of its path.
184    pub fn new() -> anyhow::Result<Self> {
185        let temp_path = tempdir()?.keep();
186
187        // If we can't get a string representation of the temp path, then its display
188        // repesentation is unlikely to be useful.
189        // So clippy's suggestion isn't helpful here.
190        #[allow(clippy::unnecessary_debug_formatting)]
191        let temp_path_string = temp_path
192            .to_str()
193            .ok_or_else(|| anyhow::anyhow!("Couldn't get a path string from {temp_path:?}"))?
194            .to_string();
195
196        Ok(Self {
197            path: temp_path,
198            path_string: temp_path_string,
199        })
200    }
201
202    /// Returns a reference to this temporary directory's path.
203    #[must_use]
204    pub fn as_path(&self) -> &Path {
205        &self.path
206    }
207
208    /// Returns a reference to this temporary path's string representation.
209    #[must_use]
210    pub fn as_str(&self) -> &str {
211        &self.path_string
212    }
213}
214
215/// Initializes a tracing subscriber for tests.
216///
217/// ## Errors
218/// Returns [`anyhow::Error`] if
219/// we can't set the tracing subscriber as the global default.
220pub fn init_tracing_subscriber() -> anyhow::Result<()> {
221    let subscriber = FmtSubscriber::builder()
222        .with_max_level(Level::INFO)
223        .finish();
224    tracing::subscriber::set_global_default(subscriber)?;
225
226    Ok(())
227}
228
229/// A temporary location for storing traces.
230///
231/// Used for tests that need to verify certain events happened
232/// based on the application traces.
233#[derive(Debug)]
234pub struct TempTracingLocation {
235    /// The temporary directory to store the trace.
236    dir: TempDir,
237
238    /// The string representation of the temp tracing path, pre-computed
239    /// in [`TempTracingLocation::new`].
240    /// Storing the pre-computed string makes it easier for users of this
241    /// structure to get a string path without having to do their own error handling.
242    dir_path: String,
243
244    /// The UUID7 prefix of the trace files in the temporary directory.
245    file_prefix: String,
246}
247
248impl TempTracingLocation {
249    /// Creates a new temporary directory for trace files, and generates their
250    /// UUID7 filename prefix.
251    ///
252    /// ## Errors
253    /// Returns [`anyhow::Error`] if we fail to:
254    /// - Create a new temporary directory
255    /// - Get the string representation of that directory's path
256    pub fn new() -> Result<Self> {
257        let dir = TempDir::new()?;
258        let dir_path = dir
259            .path()
260            .to_str()
261            .ok_or_else(|| anyhow::anyhow!("Cannot get the string path to the tracing dir."))?
262            .to_string();
263
264        let file_prefix = Uuid::now_v7().to_string();
265
266        Ok(Self {
267            dir,
268            dir_path,
269            file_prefix,
270        })
271    }
272
273    /// Returns a reference to the temporary directory which will hold the trace files.
274    #[must_use]
275    pub fn dir(&self) -> &TempDir {
276        &self.dir
277    }
278
279    /// Returns a string representation of the path, corresponding to [`Self::dir`].
280    #[must_use]
281    pub fn path(&self) -> &str {
282        &self.dir_path
283    }
284
285    /// The trace filename prefix.
286    #[must_use]
287    pub fn file_prefix(&self) -> &str {
288        &self.file_prefix
289    }
290
291    /// Iterates over the temporary directory and finds the first trace file that matches the [`Self::file_prefix`].
292    ///
293    /// ## Returns
294    /// The contents of the found trace file, if any.
295    ///
296    /// ## Errors
297    /// Returns [`anyhow::Error`] if we fail to:
298    /// - Read the temporary directory
299    /// - Get entries in that directory
300    /// - Read the entry matching the given prefix into a string
301    pub fn read_first_trace(&self) -> Result<Option<String>> {
302        let dir = fs::read_dir(self.dir())?;
303
304        for entry in dir {
305            let entry = entry?;
306            let file_name = entry.file_name();
307            let file_name = file_name.to_string_lossy();
308
309            if file_name.starts_with(self.file_prefix()) {
310                let output = fs::read_to_string(entry.path())?;
311                return Ok(Some(output));
312            }
313        }
314
315        Ok(None)
316    }
317}