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}