omry-integration 0.15.0

Helpers for Omry client applications that run integration tests against the Omry backend.
Documentation
use anyhow::{Context, Result};
use omry_service::tarpc::client;
use omry_service::tarpc::tokio_serde::formats::Bincode;
use omry_service::{FloraServiceClient, tarpc};
use std::env::consts;
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
use std::time::Duration;
use testcontainers::Image;
use tracing::{Level, instrument};

use crate::containers::{ContainerConfig, ImageConfig, ImageExtConfig, get_bridge_gateway_ip};
use crate::flora_image::{FloraConfig, FloraContainer, start_flora_container};
use crate::helpers::HostPorts;
use crate::typesense::TestTypesense;

/// Default tracing level for the flora test server.
const DEFAULT_TRACING_LEVEL: Level = Level::DEBUG;

/// Default interval at which the flora test server will sync new records to Typesense.
const DEFAULT_SYNC_INTERVAL: Duration = Duration::from_secs(10);

/// Value that corresponds to the host machine in Docker.
const DOCKER_HOST_INTERNAL: &str = "host.docker.internal";

/// Optional parameters for [`FloraStack`].
#[derive(Debug, Clone)]
pub struct FloraStackOptions {
    /// The tracing level to set for the test flora server.
    ///
    /// Corresponds to `FLORA_TRACING_LEVEL`.
    pub tracing_level: Level,

    /// The interval at which the test flora server will sync
    /// new records to Typesense.
    ///
    /// Corresponds to `FLORA_SEARCH_SYNC_INTERVAL_SECS`.
    pub search_sync_interval: Duration,
}

impl Default for FloraStackOptions {
    fn default() -> Self {
        Self {
            tracing_level: DEFAULT_TRACING_LEVEL,
            search_sync_interval: DEFAULT_SYNC_INTERVAL,
        }
    }
}

/// Flora stack (flora server and Typesense), running in testcontainers, for integration testing.
#[derive(Debug)]
pub struct FloraStack {
    /// Container for the Omry server.
    flora_server: FloraContainer,

    /// Typesense container.
    typesense: TestTypesense,
}

impl FloraStack {
    /// Creates a new instance of the Omry stack.
    ///
    /// ## Errors
    /// Returns [`anyhow::Error`] if we can't:
    ///
    /// - Start the Typesense container
    /// - Get the client config to connect to Typesense
    /// - Map the Typesense host to Docker's platform-specific localhost address (this differs between Linux and macOS)
    /// - Start the Omry container
    #[instrument]
    pub async fn new(stack_options: Option<FloraStackOptions>) -> Result<Self> {
        let typesense_img_ext_conf = ImageExtConfig::default();
        let typesense_con_conf =
            ContainerConfig::new(ImageConfig::default(), typesense_img_ext_conf);
        let typesense = TestTypesense::new(typesense_con_conf)
            .await
            .map_err(|error| {
                anyhow::anyhow!("Couldn't start the test Typesense container: {error}")
            })?;

        let client_config = typesense
            .make_client_config()
            .await
            .map_err(|error| anyhow::anyhow!("{error}"))?;
        let Some(mut typesense_url) = client_config.url else {
            anyhow::bail!("Test Typesense client config doesn't have a URL value.");
        };

        // Workaround for connecting within docker.
        let remapped_host = match typesense_url.host_str() {
            Some("127.0.0.1" | "localhost") => match consts::OS {
                "macos" => Some(DOCKER_HOST_INTERNAL.to_string()),
                "linux" => Some(get_bridge_gateway_ip().await?),
                os => {
                    tracing::warn!("your OS ({os}) may be unsupported for docker tests");
                    None
                }
            },
            _ => None,
        };

        if let Some(host) = remapped_host {
            typesense_url.set_host(Some(&host))?;
        }

        let options = stack_options.unwrap_or_default();
        let flora_conf = FloraConfig::new(&typesense_url, client_config.api_key)
            .with_tracing_level(options.tracing_level);

        let flora_image_conf = ImageConfig::default();
        let flora_image_ext_conf = ImageExtConfig::default();
        let flora_con_conf = ContainerConfig::new(flora_image_conf, flora_image_ext_conf);

        let flora_server = start_flora_container(flora_conf, flora_con_conf)
            .await
            .map_err(|error| anyhow::anyhow!("{error}"))?;

        Ok(FloraStack {
            flora_server,
            typesense,
        })
    }

    /// Stops the Docker containers in this Omry stack.
    ///
    /// ## Errors
    /// Returns [`anyhow::Result`] if we can't:
    ///
    /// - Stop the Omry container
    /// - Stop the Typesense container
    #[instrument]
    pub async fn stop(&self) -> Result<()> {
        self.flora_server.stop().await?;
        self.typesense.container().stop().await?;
        Ok(())
    }

    /// Returns a reference to the flora server test container
    /// running as part of this stack.
    #[must_use]
    pub fn flora_container(&self) -> &FloraContainer {
        &self.flora_server
    }
}

/// Spawns a new [`FloraServiceClient`] connected to the given socket.
///
/// ## Errors
///
/// Returns [`anyhow::Error`] if we can't initialize the [`tarpc`] transport.
#[instrument(skip(socket))]
pub async fn spawn_client(socket: impl tokio::net::ToSocketAddrs) -> Result<FloraServiceClient> {
    let mut transport = tarpc::serde_transport::tcp::connect(socket, Bincode::default);
    transport.config_mut().max_frame_length(usize::MAX);

    let client = FloraServiceClient::new(client::Config::default(), transport.await?).spawn();
    Ok(client)
}

/// Attempts to use [`testcontainers::ContainerAsync`] APIs
/// to get the host and host-side port for the given container.
///
/// This may not work if the container is configured to use host networking.
///
/// ## Errors
/// Returns [`anyhow::Error`] if we can't get the container host or port.
pub async fn get_host_and_ports(container: &FloraContainer) -> Result<(Ipv4Addr, HostPorts)> {
    let container_host = container
        .get_host()
        .await
        .context("couldn't get the Omry container host")?
        .to_string();

    let host_ip = if container_host == "localhost" {
        Ipv4Addr::LOCALHOST
    } else {
        anyhow::bail!("Non-localhost host is not supported yet: {container_host}")
    };

    let [tarpc_port, web_port] = container.image().expose_ports() else {
        anyhow::bail!("Omry container doesn't expose ports for tarpc and web");
    };

    let host_port_tarpc = container
        .get_host_port_ipv4(*tarpc_port)
        .await
        .context("couldn't get the tarpc host-side port for the Omry container")?;

    let host_port_web = container
        .get_host_port_ipv4(*web_port)
        .await
        .context("couldn't get the web host-side port for the Omry container")?;

    let ports = HostPorts {
        rpc: host_port_tarpc,
        web: host_port_web,
    };

    Ok((host_ip, ports))
}

/// Spawns a new [`FloraServiceClient`] connected to the Omry server
/// running in the given Docker container.
///
/// ## Errors
///
/// Returns [`anyhow::Error`] if we can't spawn the Omry service client.
#[instrument]
pub async fn flora_client(container: &FloraContainer) -> Result<FloraServiceClient> {
    let (host_ip, host_port) = match get_host_and_ports(container).await {
        Ok((host_ip, HostPorts { rpc, web: _ })) => (host_ip, rpc),
        Err(error) => {
            tracing::warn!(
                "couldn't get the host port using the testcontainers APIs; trying workaround\
                \noriginal error: {error}"
            );

            // If the host port is not `None`, that means we're running on localhost.
            let Some(ports) = container.image().host_ports() else {
                anyhow::bail!("couldn't get the host-side port for the flora container")
            };

            (Ipv4Addr::LOCALHOST, ports.rpc)
        }
    };

    let socket_addr = SocketAddr::V4(SocketAddrV4::new(host_ip, host_port));
    spawn_client(socket_addr).await
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::helpers;
    use omry_service::PONG;
    use omry_service::tarpc::context;
    use pretty_assertions::assert_eq;
    use std::time::Duration;
    use tokio::time;
    use url::Url;

    #[test]
    fn can_create_flora_config_with_options() -> Result<()> {
        let options = FloraStackOptions {
            tracing_level: Level::WARN,
            search_sync_interval: Duration::from_secs(37),
        };
        let url: Url = "https://my.test.typesense".try_into()?;
        let api_key = "very-secret".into();

        let flora_config =
            FloraConfig::new(&url, api_key).with_tracing_level(options.tracing_level);
        insta::assert_debug_snapshot!(flora_config);

        Ok(())
    }

    #[tokio::test]
    async fn can_start_flora_stack() {
        assert!(FloraStack::new(None).await.is_ok());
    }

    #[tokio::test]
    async fn can_spawn_flora_client_connected_to_test_server() -> Result<()> {
        helpers::init_tracing_subscriber()?;

        let flora_stack = FloraStack::new(None).await?;

        // Give the flora server time to start.
        time::sleep(Duration::from_secs(5)).await;

        let flora_container = flora_stack.flora_container();

        let flora_client = flora_client(flora_container)
            .await
            .context("couldn't spawn the test client")?;

        let ping_response = flora_client
            .ping(context::current())
            .await
            .context("couldn't send the ping request")?;

        assert_eq!(ping_response, PONG);

        Ok(())
    }

    #[tokio::test]
    async fn can_configure_flora_stack() -> Result<()> {
        helpers::init_tracing_subscriber()?;

        let options = FloraStackOptions::default();
        let flora_stack = FloraStack::new(Some(options)).await?;

        // Give the flora server time to start.
        time::sleep(Duration::from_secs(5)).await;

        let flora_container = flora_stack.flora_container();

        let flora_client = flora_client(flora_container)
            .await
            .context("couldn't spawn the test client")?;

        let ping_response = flora_client
            .ping(context::current())
            .await
            .context("couldn't send the ping request")?;

        insta::assert_snapshot!(ping_response, PONG);

        Ok(())
    }
}