omry-integration 0.3.0

Helpers for Omry client applications that run integration tests against the Omry backend.
Documentation
//! Helper functions and data structures for integration tests.
use crate::db::TempDb;
use omry_archiving::{Document, Marshal, Record, RecordParams, ToRecord};
use std::env;
use std::fs::{self, File};
use std::io::prelude::*;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6, TcpListener, ToSocketAddrs};
use std::path::{Path, PathBuf};
use tempfile::tempdir;
use tracing::Level;
use tracing_subscriber::FmtSubscriber;

const WORKSPACE_DIR_KEY: &str = "CARGO_WORKSPACE_DIR";
const TEST_ARCHIVE_SUBPATH: &str = "test/data/example.bincode";
const FIND_PORT_MAX_RETRIES: u8 = 5;

/// Gets a free port for the given socket from the OS. Returns the listener and the port.
///
/// The listener is returned to deal with TOCTOU, so that the OS
/// doesn't give the port to another service before we bind to it for real
/// wherever we actually need that port in our tests.
fn free_port_from_socket_addr(
    socket: impl ToSocketAddrs,
    max_attempts: u8,
) -> anyhow::Result<(TcpListener, u16)> {
    let mut attempts_left = max_attempts;
    let socket_addrs = socket.to_socket_addrs()?.collect::<Vec<_>>();

    while attempts_left > 0 {
        if let Ok(listener) = TcpListener::bind(socket_addrs.as_slice()) {
            let port = listener.local_addr()?.port();
            return Ok((listener, port));
        }
        attempts_left -= 1;
    }

    anyhow::bail!("Couldn't bind a listener to a port in {max_attempts} attempts.");
}

/// Gets a free IPv4 port from the OS. Returns the listener and the port.
///
/// The listener is returned to account for TOCTOU, so that the OS
/// doesn't release the port for another service before we bind to it for real
/// in our tests.
///
/// ## Panics
/// If the port can't be found within some number of retries (currently 3).
#[must_use]
pub fn free_port_ipv4() -> (TcpListener, u16) {
    let socket = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0);

    // Okay to panic if we can't find a free port, since we can't run integration tests
    // without a port anyway.
    #[allow(clippy::expect_used)]
    free_port_from_socket_addr(socket, FIND_PORT_MAX_RETRIES).expect("Couldn't find a free port.")
}

/// Gets a free IPv6 port from the OS. See [`free_port_ipv4`] for details about the return value.
///
/// ## Panics
/// If the port can't be found within some number of retries (currently 3).
#[must_use]
pub fn free_port_ipv6() -> (TcpListener, u16) {
    let socket = SocketAddrV6::new(Ipv6Addr::LOCALHOST, 0, 0, 0);

    // Okay to panic if we can't find a free port, since we can't run integration tests
    // without a port anyway.
    #[allow(clippy::expect_used)]
    free_port_from_socket_addr(socket, FIND_PORT_MAX_RETRIES).expect("Couldn't find a free port.")
}

fn make_test_archive_holder() -> anyhow::Result<Document> {
    let (_, workspace_dir_path) = env::vars()
        .find(|(k, _)| k == WORKSPACE_DIR_KEY)
        .ok_or_else(|| anyhow::anyhow!("{WORKSPACE_DIR_KEY} is not set"))?;

    let workspace_dir_path = fs::canonicalize(workspace_dir_path)?;
    let test_archive_path = workspace_dir_path.join(TEST_ARCHIVE_SUBPATH);

    let mut file = File::open(test_archive_path)?;
    let mut buf = vec![];
    file.read_to_end(&mut buf)?;

    let archive_holder = Document::from_bytes(&buf)?;
    Ok(archive_holder)
}

/// Makes an instance of [`RecordParams`] for tests.
///
/// ## Errors
/// Returns [`anyhow::Error`] if the test document can't be loaded.
pub fn make_test_record_params() -> anyhow::Result<RecordParams> {
    let document = make_test_archive_holder()?;
    let url = "https://example.org/".to_string();
    let title = "Example".to_string();
    let client_datetime = "2024-10-11T13:49:46-05:00".to_string();
    let language = Some("en".to_string());

    Ok(RecordParams {
        id: None,
        url,
        title,
        language,
        client_datetime,
        document,
        timestamp_flora: None,
    })
}

/// Makes a [`Record`] instance for tests.
///
/// ## Errors
/// Returns [`anyhow::Error`] if the test record can't be loaded.
pub fn make_test_record() -> anyhow::Result<Record> {
    let incoming_record = Record::new(make_test_record_params()?);
    Ok(incoming_record)
}

/// Gets records that have been inserted into the in-memory SQLite,
/// so that they have unique ids.
///
/// ## Errors
/// Returns [`anyhow::Error`] if any of the following operations fail:
///
/// - We can't create a new [`TempDb`].
/// - We can't insert the test record into SQLite.
/// - We can't get that record back from SQLite.
pub async fn make_test_db_records(how_many: usize) -> anyhow::Result<Vec<impl ToRecord>> {
    let db = TempDb::new().await?;
    let mut db_records = Vec::with_capacity(how_many);
    for _ in 0..how_many {
        let record = make_test_record()?;
        let row_id = db.insert(record.try_into()?).await?;
        let db_record = db.get_record(row_id).await?;
        db_records.push(db_record);
    }
    Ok(db_records)
}

/// Temporary path and its string representation.
#[derive(Debug)]
pub struct TempPathWithString {
    path: PathBuf,
    path_string: String,
}

impl TempPathWithString {
    /// Creates a new [`tempfile::TempDir`], and stores its associated path
    /// and string representation.
    ///
    /// ## Errors
    /// If we can't create a temporary directory or construct the string representation
    /// of its path.
    pub fn new() -> anyhow::Result<Self> {
        let temp_path = tempdir()?.keep();

        // If we can't get a string representation of the temp path, then its display
        // repesentation is unlikely to be useful.
        // So clippy's suggestion isn't helpful here.
        #[allow(clippy::unnecessary_debug_formatting)]
        let temp_path_string = temp_path
            .to_str()
            .ok_or_else(|| anyhow::anyhow!("Couldn't get a path string from {temp_path:?}"))?
            .to_string();

        Ok(Self {
            path: temp_path,
            path_string: temp_path_string,
        })
    }

    /// Returns a reference to this temporary directory's path.
    #[must_use]
    pub fn as_path(&self) -> &Path {
        &self.path
    }

    /// Returns a reference to this temporary path's string representation.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.path_string
    }
}

/// Initializes a tracing subscriber for tests.
///
/// ## Errors
/// Returns [`anyhow::Error`] if
/// we can't set the tracing subscriber as the global default.
pub fn init_tracing_subscriber() -> anyhow::Result<()> {
    let subscriber = FmtSubscriber::builder()
        .with_max_level(Level::INFO)
        .finish();
    tracing::subscriber::set_global_default(subscriber)?;

    Ok(())
}