omry_integration/
full_stack.rs

1//! "Stack" that includes a simple Omry client app, to facilitate integration tests
2//! with a containerised Omry server.
3use crate::flora_stack::{FloraStack, FloraStackOptions};
4use anyhow::Result;
5use std::sync::Arc;
6use std::time::Duration;
7pub use test_app::App;
8use tracing::instrument;
9
10/// Omry client app for tests.
11mod test_app {
12    use crate::bridge::ServiceBridge;
13    use crate::flora_stack::{self, FloraStack};
14    use anyhow::{Context, Result};
15    use omry_service::FloraServiceClient;
16    use omry_service::search::{SearchParameters, SearchResponse};
17    use omry_service::tarpc::context;
18    use std::sync::Arc;
19    use tokio::time::{self, Duration};
20    use tracing::instrument;
21
22    /// Client "app" for testing.
23    #[derive(Debug, Clone)]
24    pub struct App {
25        /// Bridge API for the flora tarpc backend.
26        client: FloraServiceClient,
27    }
28
29    impl App {
30        /// Initializes the app state.
31        #[must_use]
32        pub fn new(client: FloraServiceClient) -> Self {
33            Self { client }
34        }
35
36        /// Constructs [`SearchParameters`] with the given text query,
37        /// and sends a search request to the flora backend.
38        ///
39        /// ## Errors
40        /// Returns [`anyhow::Error`] if the search RPC fails.
41        #[instrument]
42        pub async fn search(&self, query: String) -> Result<SearchResponse> {
43            // flora search fields against which we'll query.
44            const QUERY_BY: &[&str] = &["title", "text-body"];
45
46            let query_by = QUERY_BY
47                .iter()
48                .map(std::string::ToString::to_string)
49                .collect::<Vec<_>>();
50
51            let params = SearchParameters {
52                query,
53                query_by,
54                num_typos: Some(2),
55                filter_by: None,
56            };
57
58            ServiceBridge::search(self, params.into())
59                .await
60                .context("Couldn't call search")
61        }
62    }
63
64    impl ServiceBridge for App {
65        fn client(&self) -> &FloraServiceClient {
66            &self.client
67        }
68    }
69
70    /// Initializes a test client app, and checks that it can communicate
71    /// with the Omry server.
72    #[instrument]
73    pub(super) async fn init_app(flora_stack: Arc<FloraStack>) -> Result<App> {
74        const MAX_CONN_ATTEMPTS: usize = 5;
75        const BACKOFF_MULTIPLIER: u32 = 2;
76
77        tokio::spawn(async move {
78            let mut attempts_so_far = 0;
79            let mut backoff_interval = Duration::from_secs(1);
80
81            // Attempt to first create an Omry RPC client, then use that client
82            // to initialize our RPC bridge.
83            //
84            // We need to recreate the client on every iteration of the loop
85            // even if we initialized it successfully in a previous iteration
86            // because the client's connection to the tarpc server can shut down
87            // when we use tokio::time::sleep.
88
89            loop {
90                match flora_stack::flora_client(flora_stack.flora_container()).await {
91                    Ok(client) => match client.ping(context::current()).await {
92                        Ok(_) => break Ok(App::new(client)),
93                        Err(error) => {
94                            tracing::warn!("error while initializing the tarpc client: {error}");
95                        }
96                    },
97                    Err(error) => {
98                        tracing::warn!("error while creating the flora client: {error}");
99                    }
100                }
101
102                if attempts_so_far == MAX_CONN_ATTEMPTS {
103                    anyhow::bail!("couldn't succeed after {MAX_CONN_ATTEMPTS} attempts");
104                }
105
106                time::sleep(backoff_interval).await;
107                backoff_interval *= BACKOFF_MULTIPLIER;
108                attempts_so_far += 1;
109            }
110        })
111        .await?
112    }
113}
114
115/// Flora test stack.
116#[derive(Debug)]
117pub struct OmryFullStack {
118    /// Containerised Omry stack.
119    backend: Arc<FloraStack>,
120
121    /// Simple 'client' app.
122    app: App,
123}
124
125impl OmryFullStack {
126    /// Creates a new test stack that includes an Omry backend (`omry-server` and Typesense),
127    /// and a client app to facilitate integration testing.
128    ///
129    /// ## Errors
130    ///
131    /// Returns [`anyhow::Error`] if we fail to:
132    /// - Initialize the backend stack
133    /// - Initialize the client app and connect it to that backend stack
134    #[instrument]
135    pub async fn new() -> Result<Self> {
136        let stack_options = FloraStackOptions {
137            search_sync_interval: Duration::from_secs(5),
138            ..Default::default()
139        };
140
141        let backend = Arc::new(FloraStack::new(Some(stack_options)).await?);
142        let app = test_app::init_app(Arc::clone(&backend)).await?;
143
144        tracing::info!("successfully initialized the test bridge");
145        Ok(Self { backend, app })
146    }
147
148    /// Returns a reference to the test client app.
149    #[must_use]
150    pub fn app(&self) -> &App {
151        &self.app
152    }
153
154    /// Attempts to stop ("kill") this Omry stack, shutting down
155    /// the connection(s) with the Omry backend.
156    ///
157    /// ## Errors
158    /// Returns [`anyhow::Error`] if we can't stop the Omry backend.
159    pub async fn kill_flora_rpc(&mut self) -> Result<()> {
160        self.backend.stop().await?;
161        Ok(())
162    }
163}