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