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