Skip to main content

pubky_testnet/
ephemeral_testnet.rs

1use crate::Testnet;
2use http_relay::HttpRelay;
3use pubky::{Keypair, Pubky};
4use pubky_homeserver::{ConfigToml, ConnectionString, HomeserverApp, MockDataDir};
5
6#[cfg(feature = "embedded-postgres")]
7use crate::embedded_postgres::EmbeddedPostgres;
8
9/// A simple testnet with random ports assigned for all components.
10///
11/// Components included:
12/// - A local DHT with bootstrapping nodes.
13/// - A homeserver (default pubkey: `8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo`).
14/// - An HTTP relay (optional, use `.with_http_relay()` to enable).
15///
16/// # Recommended Usage
17/// Use [`EphemeralTestnet::builder()`] to create a testnet with explicit configuration:
18///
19/// ```ignore
20/// // Minimal testnet (admin/metrics disabled) - fastest for most tests
21/// let testnet = EphemeralTestnet::builder().build().await?;
22///
23/// // Full-featured testnet (admin enabled) - for tests requiring admin API
24/// let testnet = EphemeralTestnet::builder()
25///     .config(ConfigToml::default_test_config())
26///     .build()
27///     .await?;
28/// ```
29///
30/// # Configuration Defaults
31/// - [`EphemeralTestnet::builder().build()`] uses [`ConfigToml::minimal_test_config()`] (admin/metrics **disabled**)
32/// - Deprecated [`EphemeralTestnet::start()`] uses [`ConfigToml::default_test_config()`] (admin **enabled**)
33pub struct EphemeralTestnet {
34    /// Inner flexible testnet.
35    pub testnet: Testnet,
36    /// Embedded PostgreSQL instance (if using embedded postgres).
37    /// Kept alive as long as the testnet is running.
38    #[cfg(feature = "embedded-postgres")]
39    #[allow(dead_code)]
40    embedded_postgres: Option<EmbeddedPostgres>,
41}
42
43/// Builder for configuring and creating an [`EphemeralTestnet`].
44///
45/// Provides a fluent API for customizing testnet configuration before creation.
46///
47/// # Defaults
48/// - **Config**: [`ConfigToml::minimal_test_config()`] (admin/metrics disabled)
49/// - **Keypair**: Deterministic keypair from `[0; 32]` secret key
50/// - **Postgres**: Uses `TEST_PUBKY_CONNECTION_STRING` env var if set, otherwise in-memory
51/// - **HTTP Relay**: Disabled by default (use `.with_http_relay()` to enable)
52///
53/// # Example
54/// ```ignore
55/// // Use defaults (minimal config, no HTTP relay)
56/// let testnet = EphemeralTestnet::builder().build().await?;
57///
58/// // Enable admin server
59/// let testnet = EphemeralTestnet::builder()
60///     .config(ConfigToml::default_test_config())
61///     .build()
62///     .await?;
63///
64/// // Custom keypair
65/// let testnet = EphemeralTestnet::builder()
66///     .keypair(Keypair::random())
67///     .build()
68///     .await?;
69///
70/// // With HTTP relay (for tests that need it)
71/// let testnet = EphemeralTestnet::builder()
72///     .with_http_relay()
73///     .build()
74///     .await?;
75/// ```
76pub struct EphemeralTestnetBuilder {
77    postgres_connection_string: Option<ConnectionString>,
78    homeserver_config: Option<ConfigToml>,
79    homeserver_keypair: Option<Keypair>,
80    http_relay: bool,
81    #[cfg(feature = "embedded-postgres")]
82    use_embedded_postgres: bool,
83}
84
85impl EphemeralTestnetBuilder {
86    /// Create a new builder with default configuration.
87    pub fn new() -> Self {
88        Self {
89            postgres_connection_string: None,
90            homeserver_config: None,
91            homeserver_keypair: None,
92            http_relay: false,
93            #[cfg(feature = "embedded-postgres")]
94            use_embedded_postgres: false,
95        }
96    }
97
98    /// Set a custom homeserver configuration.
99    pub fn config(mut self, config: ConfigToml) -> Self {
100        self.homeserver_config = Some(config);
101        self
102    }
103
104    /// Set a specific keypair for the homeserver.
105    pub fn keypair(mut self, keypair: Keypair) -> Self {
106        self.homeserver_keypair = Some(keypair);
107        self
108    }
109
110    /// Set a custom postgres connection string.
111    pub fn postgres(mut self, connection_string: ConnectionString) -> Self {
112        self.postgres_connection_string = Some(connection_string);
113        self
114    }
115
116    /// Enable the HTTP relay (disabled by default).
117    pub fn with_http_relay(mut self) -> Self {
118        self.http_relay = true;
119        self
120    }
121
122    /// Use embedded PostgreSQL instead of an external database.
123    ///
124    /// This starts an embedded PostgreSQL instance that is automatically
125    /// downloaded and managed. The first run will download the PostgreSQL
126    /// binaries (~50-100MB), which are cached for subsequent runs.
127    ///
128    /// This is useful for running tests without requiring a separate
129    /// PostgreSQL installation.
130    ///
131    /// **Note**: Cannot be combined with `.postgres()`. If both are set, `build()` will
132    /// return an error.
133    ///
134    /// # Multiple Tests
135    ///
136    /// Each call to `.with_embedded_postgres()` starts a separate PostgreSQL server.
137    /// If you have many tests, prefer starting one [`EmbeddedPostgres`](crate::embedded_postgres::EmbeddedPostgres)
138    /// instance and passing its connection string via `.postgres()` instead.
139    /// See [`EmbeddedPostgres`](crate::embedded_postgres::EmbeddedPostgres) docs for the recommended pattern.
140    #[cfg(feature = "embedded-postgres")]
141    pub fn with_embedded_postgres(mut self) -> Self {
142        self.use_embedded_postgres = true;
143        self
144    }
145
146    /// Build and start the testnet with the configured settings.
147    /// Uses minimal_test_config() by default (admin/metrics disabled).
148    ///
149    /// # Errors
150    /// Returns an error if both `.postgres()` and `.with_embedded_postgres()` are set.
151    pub async fn build(self) -> anyhow::Result<EphemeralTestnet> {
152        #[cfg(feature = "embedded-postgres")]
153        if self.use_embedded_postgres && self.postgres_connection_string.is_some() {
154            anyhow::bail!(
155                "Cannot use both embedded postgres and a custom connection string. \
156                 Use either .with_embedded_postgres() or .postgres(), not both."
157            );
158        }
159
160        #[cfg(feature = "embedded-postgres")]
161        let (embedded_postgres, postgres_connection_string) = if self.use_embedded_postgres {
162            let embedded = EmbeddedPostgres::start().await?;
163            let conn_string = embedded.connection_string()?;
164            (Some(embedded), Some(conn_string))
165        } else {
166            (None, self.postgres_connection_string)
167        };
168
169        #[cfg(not(feature = "embedded-postgres"))]
170        let postgres_connection_string = self.postgres_connection_string;
171
172        let mut testnet = if let Some(postgres) = postgres_connection_string {
173            Testnet::new_with_custom_postgres(postgres).await?
174        } else {
175            Testnet::new().await?
176        };
177
178        if self.http_relay {
179            testnet.create_http_relay().await?;
180        }
181
182        let mut config = self
183            .homeserver_config
184            .unwrap_or_else(ConfigToml::minimal_test_config);
185
186        if let Some(connection_string) = testnet.postgres_connection_string.as_ref() {
187            config.general.database_url = connection_string.clone();
188        }
189
190        let keypair = self
191            .homeserver_keypair
192            .unwrap_or_else(|| Keypair::from_secret(&[0; 32]));
193        let mock_dir = MockDataDir::new(config, Some(keypair))?;
194        testnet.create_homeserver_app_with_mock(mock_dir).await?;
195
196        Ok(EphemeralTestnet {
197            testnet,
198            #[cfg(feature = "embedded-postgres")]
199            embedded_postgres,
200        })
201    }
202}
203
204impl Default for EphemeralTestnetBuilder {
205    fn default() -> Self {
206        Self::new()
207    }
208}
209
210impl EphemeralTestnet {
211    /// Create a new builder for configuring the testnet.
212    ///
213    /// This is the recommended way to create a testnet with custom configuration.
214    ///
215    /// # Example
216    /// ```ignore
217    /// let testnet = EphemeralTestnet::builder()
218    ///     .config(ConfigToml::default_test_config())
219    ///     .keypair(Keypair::random())
220    ///     .build()
221    ///     .await?;
222    /// ```
223    pub fn builder() -> EphemeralTestnetBuilder {
224        EphemeralTestnetBuilder::new()
225    }
226
227    /// Run a new simple testnet with full config (admin enabled).
228    ///
229    /// # Deprecated
230    /// Use [`Self::builder()`] for explicit configuration control.
231    /// This method uses [`ConfigToml::default_test_config()`] which enables the admin server.
232    #[deprecated(
233        since = "0.5.0",
234        note = "Use EphemeralTestnet::builder().config(ConfigToml::default_test_config()).build() for explicit behavior"
235    )]
236    pub async fn start() -> anyhow::Result<Self> {
237        let mut testnet = Testnet::new().await?;
238        testnet.create_http_relay().await?;
239        testnet.create_homeserver().await?;
240        Ok(Self {
241            testnet,
242            #[cfg(feature = "embedded-postgres")]
243            embedded_postgres: None,
244        })
245    }
246
247    /// Run a new simple testnet with custom postgres and full config (admin enabled).
248    ///
249    /// # Deprecated
250    /// Use [`Self::builder()`] with `.postgres()` for explicit configuration control.
251    #[deprecated(
252        since = "0.5.0",
253        note = "Use EphemeralTestnet::builder().postgres(...).config(ConfigToml::default_test_config()).build() instead"
254    )]
255    pub async fn start_with_custom_postgres(
256        postgres_connection_string: ConnectionString,
257    ) -> anyhow::Result<Self> {
258        let mut testnet = Testnet::new_with_custom_postgres(postgres_connection_string).await?;
259        testnet.create_http_relay().await?;
260        testnet.create_homeserver().await?;
261        Ok(Self {
262            testnet,
263            #[cfg(feature = "embedded-postgres")]
264            embedded_postgres: None,
265        })
266    }
267
268    /// Run a new simple testnet with custom postgres but no homeserver (minimal setup).
269    ///
270    /// # Deprecated
271    /// Use [`Testnet`] directly for fine-grained control over component creation.
272    #[deprecated(
273        since = "0.5.0",
274        note = "Use Testnet::new_with_custom_postgres() and create_http_relay() for fine-grained control"
275    )]
276    pub async fn start_minimal_with_custom_postgres(
277        postgres_connection_string: ConnectionString,
278    ) -> anyhow::Result<Self> {
279        let mut me = Self {
280            testnet: Testnet::new_with_custom_postgres(postgres_connection_string).await?,
281            #[cfg(feature = "embedded-postgres")]
282            embedded_postgres: None,
283        };
284        me.testnet.create_http_relay().await?;
285        Ok(me)
286    }
287
288    /// Run a new simple testnet network with a minimal setup (no homeserver).
289    ///
290    /// # Deprecated
291    /// Use [`Testnet`] directly for fine-grained control over component creation.
292    #[deprecated(
293        since = "0.5.0",
294        note = "Use Testnet::new() and create_http_relay() for fine-grained control"
295    )]
296    pub async fn start_minimal() -> anyhow::Result<Self> {
297        let mut me = Self {
298            testnet: Testnet::new().await?,
299            #[cfg(feature = "embedded-postgres")]
300            embedded_postgres: None,
301        };
302        me.testnet.create_http_relay().await?;
303        Ok(me)
304    }
305
306    /// Create an additional homeserver with a random keypair.
307    pub async fn create_random_homeserver(&mut self) -> anyhow::Result<&HomeserverApp> {
308        self.create_random_homeserver_with_config(None).await
309    }
310
311    /// Create an additional homeserver with a random keypair and custom config.
312    /// Uses minimal_test_config() by default (admin/metrics disabled).
313    pub async fn create_random_homeserver_with_config(
314        &mut self,
315        config: Option<ConfigToml>,
316    ) -> anyhow::Result<&HomeserverApp> {
317        let mut config = config.unwrap_or_else(ConfigToml::minimal_test_config);
318
319        if let Some(connection_string) = self.testnet.postgres_connection_string.as_ref() {
320            config.general.database_url = connection_string.clone();
321        }
322
323        let mock_dir = MockDataDir::new(config, Some(Keypair::random()))?;
324        self.testnet.create_homeserver_app_with_mock(mock_dir).await
325    }
326
327    /// Create a new pubky client builder.
328    pub fn client_builder(&self) -> pubky::PubkyHttpClientBuilder {
329        self.testnet.client_builder()
330    }
331
332    /// Creates a [`pubky::PubkyHttpClient`] pre-configured to use this test network.
333    pub fn client(&self) -> Result<pubky::PubkyHttpClient, pubky::BuildError> {
334        self.testnet.client()
335    }
336
337    /// Creates a [`pubky::Pubky`] SDK facade pre-configured to use this test network.
338    ///
339    /// This is a convenience method that builds a client from `Self::client_builder`.
340    pub fn sdk(&self) -> Result<Pubky, pubky::BuildError> {
341        self.testnet.sdk()
342    }
343
344    /// Create a new pkarr client builder.
345    pub fn pkarr_client_builder(&self) -> pkarr::ClientBuilder {
346        self.testnet.pkarr_client_builder()
347    }
348
349    /// Get the homeserver in the testnet.
350    pub fn homeserver_app(&self) -> &pubky_homeserver::HomeserverApp {
351        self.testnet
352            .homeservers
353            .first()
354            .expect("homeservers should be non-empty")
355    }
356
357    /// Get the http relay in the testnet.
358    pub fn http_relay(&self) -> &HttpRelay {
359        self.testnet
360            .http_relays
361            .first()
362            .expect("no http relay configured - use .with_http_relay() when building")
363    }
364}
365
366#[cfg(test)]
367mod test {
368    use super::*;
369
370    /// Test that two testnets can be run in a row.
371    /// This is to prevent the case where the testnet is not cleaned up properly.
372    /// For example, if the port is not released after the testnet is stopped.
373    #[tokio::test]
374    async fn test_two_testnet_in_a_row() {
375        {
376            let _ = EphemeralTestnet::builder().build().await.unwrap();
377        }
378
379        {
380            let _ = EphemeralTestnet::builder().build().await.unwrap();
381        }
382    }
383
384    #[tokio::test]
385    async fn test_homeserver_with_random_keypair() {
386        // Start with just DHT + http relay, no homeserver
387        let mut testnet = Testnet::new().await.unwrap();
388        testnet.create_http_relay().await.unwrap();
389        let mut network = EphemeralTestnet {
390            testnet,
391            #[cfg(feature = "embedded-postgres")]
392            embedded_postgres: None,
393        };
394        assert!(network.testnet.homeservers.is_empty());
395
396        let _ = network.create_random_homeserver().await.unwrap();
397        let _ = network.create_random_homeserver().await.unwrap();
398        assert!(network.testnet.homeservers.len() == 2);
399
400        // The two newly created homeservers must have distinct public keys.
401        assert_ne!(
402            network.testnet.homeservers[0].public_key(),
403            network.testnet.homeservers[1].public_key()
404        );
405    }
406
407    #[tokio::test]
408    async fn test_builder_default() {
409        // Verify builder creates homeserver with minimal config (admin disabled)
410        let network = EphemeralTestnet::builder().build().await.unwrap();
411        let homeserver = network.homeserver_app();
412
413        // The builder should use minimal_test_config() by default (admin disabled)
414        assert!(
415            homeserver.admin_server().is_none(),
416            "Builder should use minimal config with admin disabled by default"
417        );
418        assert!(
419            homeserver.metrics_server().is_none(),
420            "Builder should use minimal config with metrics disabled by default"
421        );
422    }
423
424    #[tokio::test]
425    async fn test_builder_with_custom_config() {
426        // Verify custom config is used (e.g., metrics enabled)
427        let mut config = ConfigToml::minimal_test_config();
428        config.metrics.enabled = true;
429
430        let network = EphemeralTestnet::builder()
431            .config(config)
432            .build()
433            .await
434            .unwrap();
435
436        let homeserver = network.homeserver_app();
437        assert!(
438            homeserver.metrics_server().is_some(),
439            "Custom config should enable metrics"
440        );
441        assert!(
442            homeserver.admin_server().is_none(),
443            "Custom config should keep admin disabled"
444        );
445    }
446
447    #[tokio::test]
448    async fn test_builder_with_custom_keypair() {
449        // Verify custom keypair is used
450        let keypair = Keypair::random();
451        let expected_public_key = keypair.public_key();
452
453        let network = EphemeralTestnet::builder()
454            .keypair(keypair)
455            .build()
456            .await
457            .unwrap();
458
459        let homeserver = network.homeserver_app();
460        assert_eq!(
461            homeserver.public_key(),
462            expected_public_key,
463            "Custom keypair should be used"
464        );
465    }
466}