omry-integration 0.13.1

Helpers for Omry client applications that run integration tests against the Omry backend.
Documentation
//! "Stack" that includes a simple Omry client app, to facilitate integration tests
//! with a containerised Omry server.
use crate::flora_stack::{FloraStack, FloraStackOptions};
use anyhow::Result;
use std::sync::Arc;
use std::time::Duration;
pub use test_app::App;
use tracing::instrument;

/// Omry client app for tests.
mod test_app {
    use crate::bridge::ServiceBridge;
    use crate::flora_stack::{self, FloraStack};
    use anyhow::{Context, Result};
    use omry_service::FloraServiceClient;
    use omry_service::search::{SearchParameters, SearchResponse, SortBy};
    use omry_service::tarpc::context;
    use std::sync::Arc;
    use tokio::time::{self, Duration};
    use tracing::instrument;

    /// Client "app" for testing.
    #[derive(Debug, Clone)]
    pub struct App {
        /// Bridge API for the flora tarpc backend.
        client: FloraServiceClient,
    }

    impl App {
        /// Initializes the app state.
        #[must_use]
        pub fn new(client: FloraServiceClient) -> Self {
            Self { client }
        }

        /// Constructs [`SearchParameters`] with the given text query,
        /// and sends a search request to the Omry backend.
        ///
        /// Adds `sort_by` to the search parameters to sort hits by recency.
        ///
        /// ## Errors
        /// Returns [`anyhow::Error`] if the search RPC fails.
        #[instrument]
        pub async fn search(&self, query: String) -> Result<SearchResponse> {
            const BUCKETS: u32 = 5;
            const QUERY_BY: &[&str] = &["title", "text-body"];

            let query_by = QUERY_BY
                .iter()
                .map(std::string::ToString::to_string)
                .collect::<Vec<_>>();

            let sort_by = SortBy::relevance_and_recency(BUCKETS);
            let params = SearchParameters {
                query,
                query_by,
                num_typos: Some(2),
                filter_by: None,
                sort_by: Some(sort_by),
            };

            ServiceBridge::search(self, params.into())
                .await
                .context("Couldn't call search")
        }
    }

    impl ServiceBridge for App {
        fn client(&self) -> &FloraServiceClient {
            &self.client
        }
    }

    /// Initializes a test client app, and checks that it can communicate
    /// with the Omry server.
    #[instrument]
    pub(super) async fn init_app(flora_stack: Arc<FloraStack>) -> Result<App> {
        const MAX_CONN_ATTEMPTS: usize = 5;
        const BACKOFF_MULTIPLIER: u32 = 2;

        tokio::spawn(async move {
            let mut attempts_so_far = 0;
            let mut backoff_interval = Duration::from_secs(1);

            // Attempt to first create an Omry RPC client, then use that client
            // to initialize our RPC bridge.
            //
            // We need to recreate the client on every iteration of the loop
            // even if we initialized it successfully in a previous iteration
            // because the client's connection to the tarpc server can shut down
            // when we use tokio::time::sleep.

            loop {
                match flora_stack::flora_client(flora_stack.flora_container()).await {
                    Ok(client) => match client.ping(context::current()).await {
                        Ok(_) => break Ok(App::new(client)),
                        Err(error) => {
                            tracing::warn!("error while initializing the tarpc client: {error}");
                        }
                    },
                    Err(error) => {
                        tracing::warn!("error while creating the flora client: {error}");
                    }
                }

                if attempts_so_far == MAX_CONN_ATTEMPTS {
                    anyhow::bail!("couldn't succeed after {MAX_CONN_ATTEMPTS} attempts");
                }

                time::sleep(backoff_interval).await;
                backoff_interval *= BACKOFF_MULTIPLIER;
                attempts_so_far += 1;
            }
        })
        .await?
    }
}

/// Flora test stack.
#[derive(Debug)]
pub struct OmryFullStack {
    /// Containerised Omry stack.
    backend: Arc<FloraStack>,

    /// Simple 'client' app.
    app: App,
}

impl OmryFullStack {
    /// Creates a new test stack that includes an Omry backend (`omry-server` and Typesense),
    /// and a client app to facilitate integration testing.
    ///
    /// ## Errors
    ///
    /// Returns [`anyhow::Error`] if we fail to:
    /// - Initialize the backend stack
    /// - Initialize the client app and connect it to that backend stack
    #[instrument]
    pub async fn new() -> Result<Self> {
        let stack_options = FloraStackOptions {
            search_sync_interval: Duration::from_secs(5),
            ..Default::default()
        };

        let backend = Arc::new(FloraStack::new(Some(stack_options)).await?);
        let app = test_app::init_app(Arc::clone(&backend)).await?;

        tracing::info!("successfully initialized the test bridge");
        Ok(Self { backend, app })
    }

    /// Returns a reference to the test client app.
    #[must_use]
    pub fn app(&self) -> &App {
        &self.app
    }

    /// Attempts to stop ("kill") this Omry stack, shutting down
    /// the connection(s) with the Omry backend.
    ///
    /// ## Errors
    /// Returns [`anyhow::Error`] if we can't stop the Omry backend.
    pub async fn kill_flora_rpc(&mut self) -> Result<()> {
        self.backend.stop().await?;
        Ok(())
    }
}