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 = "docker-postgres")]
7use crate::docker_postgres::DockerPostgres;
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    /// Docker PostgreSQL instance (if using docker postgres).
37    /// Kept alive as long as the testnet is running.
38    #[cfg(feature = "docker-postgres")]
39    #[allow(dead_code)]
40    docker_postgres: Option<DockerPostgres>,
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 = "docker-postgres")]
82    use_docker_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 = "docker-postgres")]
94            use_docker_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 a Docker PostgreSQL container instead of an external database.
123    ///
124    /// This starts a PostgreSQL container via testcontainers that is automatically
125    /// managed and cleaned up. Requires Docker to be running on the host.
126    ///
127    /// This is useful for running tests without requiring a separate
128    /// PostgreSQL installation.
129    ///
130    /// **Note**: Cannot be combined with `.postgres()`. If both are set, `build()` will
131    /// return an error.
132    ///
133    /// # Multiple Tests
134    ///
135    /// Each call to `.with_docker_postgres()` starts a separate PostgreSQL container.
136    /// If you have many tests, prefer starting one [`DockerPostgres`](crate::docker_postgres::DockerPostgres)
137    /// instance and passing its connection string via `.postgres()` instead.
138    /// See [`DockerPostgres`](crate::docker_postgres::DockerPostgres) docs for the recommended pattern.
139    #[cfg(feature = "docker-postgres")]
140    pub fn with_docker_postgres(mut self) -> Self {
141        self.use_docker_postgres = true;
142        self
143    }
144
145    /// Deprecated alias for [`Self::with_docker_postgres()`].
146    #[cfg(feature = "docker-postgres")]
147    #[deprecated(since = "0.9.0", note = "Renamed to `with_docker_postgres()`")]
148    pub fn with_embedded_postgres(self) -> Self {
149        self.with_docker_postgres()
150    }
151
152    /// Build and start the testnet with the configured settings.
153    /// Uses minimal_test_config() by default (admin/metrics disabled).
154    ///
155    /// # Errors
156    /// Returns an error if both `.postgres()` and `.with_docker_postgres()` are set.
157    pub async fn build(self) -> anyhow::Result<EphemeralTestnet> {
158        #[cfg(feature = "docker-postgres")]
159        if self.use_docker_postgres && self.postgres_connection_string.is_some() {
160            anyhow::bail!(
161                "Cannot use both docker postgres and a custom connection string. \
162                 Use either .with_docker_postgres() or .postgres(), not both."
163            );
164        }
165
166        #[cfg(feature = "docker-postgres")]
167        let (docker_postgres, postgres_connection_string) = if self.use_docker_postgres {
168            let pg = DockerPostgres::start().await?;
169            let conn_string = pg.connection_string()?;
170            (Some(pg), Some(conn_string))
171        } else {
172            (None, self.postgres_connection_string)
173        };
174
175        #[cfg(not(feature = "docker-postgres"))]
176        let postgres_connection_string = self.postgres_connection_string;
177
178        let mut testnet = if let Some(postgres) = postgres_connection_string {
179            Testnet::new_with_custom_postgres(postgres).await?
180        } else {
181            Testnet::new().await?
182        };
183
184        if self.http_relay {
185            testnet.create_http_relay().await?;
186        }
187
188        let mut config = self
189            .homeserver_config
190            .unwrap_or_else(ConfigToml::minimal_test_config);
191
192        if let Some(connection_string) = testnet.postgres_connection_string.as_ref() {
193            config.general.database_url = connection_string.clone();
194        }
195
196        let keypair = self
197            .homeserver_keypair
198            .unwrap_or_else(|| Keypair::from_secret(&[0; 32]));
199        let mock_dir = MockDataDir::new(config, Some(keypair))?;
200        testnet.create_homeserver_app_with_mock(mock_dir).await?;
201
202        Ok(EphemeralTestnet {
203            testnet,
204            #[cfg(feature = "docker-postgres")]
205            docker_postgres,
206        })
207    }
208}
209
210impl Default for EphemeralTestnetBuilder {
211    fn default() -> Self {
212        Self::new()
213    }
214}
215
216impl EphemeralTestnet {
217    /// Create a new builder for configuring the testnet.
218    ///
219    /// This is the recommended way to create a testnet with custom configuration.
220    ///
221    /// # Example
222    /// ```ignore
223    /// let testnet = EphemeralTestnet::builder()
224    ///     .config(ConfigToml::default_test_config())
225    ///     .keypair(Keypair::random())
226    ///     .build()
227    ///     .await?;
228    /// ```
229    pub fn builder() -> EphemeralTestnetBuilder {
230        EphemeralTestnetBuilder::new()
231    }
232
233    /// Run a new simple testnet with full config (admin enabled).
234    ///
235    /// # Deprecated
236    /// Use [`Self::builder()`] for explicit configuration control.
237    /// This method uses [`ConfigToml::default_test_config()`] which enables the admin server.
238    #[deprecated(
239        since = "0.5.0",
240        note = "Use EphemeralTestnet::builder().config(ConfigToml::default_test_config()).build() for explicit behavior"
241    )]
242    pub async fn start() -> anyhow::Result<Self> {
243        let mut testnet = Testnet::new().await?;
244        testnet.create_http_relay().await?;
245        testnet.create_homeserver().await?;
246        Ok(Self {
247            testnet,
248            #[cfg(feature = "docker-postgres")]
249            docker_postgres: None,
250        })
251    }
252
253    /// Run a new simple testnet with custom postgres and full config (admin enabled).
254    ///
255    /// # Deprecated
256    /// Use [`Self::builder()`] with `.postgres()` for explicit configuration control.
257    #[deprecated(
258        since = "0.5.0",
259        note = "Use EphemeralTestnet::builder().postgres(...).config(ConfigToml::default_test_config()).build() instead"
260    )]
261    pub async fn start_with_custom_postgres(
262        postgres_connection_string: ConnectionString,
263    ) -> anyhow::Result<Self> {
264        let mut testnet = Testnet::new_with_custom_postgres(postgres_connection_string).await?;
265        testnet.create_http_relay().await?;
266        testnet.create_homeserver().await?;
267        Ok(Self {
268            testnet,
269            #[cfg(feature = "docker-postgres")]
270            docker_postgres: None,
271        })
272    }
273
274    /// Run a new simple testnet with custom postgres but no homeserver (minimal setup).
275    ///
276    /// # Deprecated
277    /// Use [`Testnet`] directly for fine-grained control over component creation.
278    #[deprecated(
279        since = "0.5.0",
280        note = "Use Testnet::new_with_custom_postgres() and create_http_relay() for fine-grained control"
281    )]
282    pub async fn start_minimal_with_custom_postgres(
283        postgres_connection_string: ConnectionString,
284    ) -> anyhow::Result<Self> {
285        let mut me = Self {
286            testnet: Testnet::new_with_custom_postgres(postgres_connection_string).await?,
287            #[cfg(feature = "docker-postgres")]
288            docker_postgres: None,
289        };
290        me.testnet.create_http_relay().await?;
291        Ok(me)
292    }
293
294    /// Run a new simple testnet network with a minimal setup (no homeserver).
295    ///
296    /// # Deprecated
297    /// Use [`Testnet`] directly for fine-grained control over component creation.
298    #[deprecated(
299        since = "0.5.0",
300        note = "Use Testnet::new() and create_http_relay() for fine-grained control"
301    )]
302    pub async fn start_minimal() -> anyhow::Result<Self> {
303        let mut me = Self {
304            testnet: Testnet::new().await?,
305            #[cfg(feature = "docker-postgres")]
306            docker_postgres: None,
307        };
308        me.testnet.create_http_relay().await?;
309        Ok(me)
310    }
311
312    /// Create an additional homeserver with a random keypair.
313    pub async fn create_random_homeserver(&mut self) -> anyhow::Result<&HomeserverApp> {
314        self.create_random_homeserver_with_config(None).await
315    }
316
317    /// Create an additional homeserver with a random keypair and custom config.
318    /// Uses minimal_test_config() by default (admin/metrics disabled).
319    pub async fn create_random_homeserver_with_config(
320        &mut self,
321        config: Option<ConfigToml>,
322    ) -> anyhow::Result<&HomeserverApp> {
323        let mut config = config.unwrap_or_else(ConfigToml::minimal_test_config);
324
325        if let Some(connection_string) = self.testnet.postgres_connection_string.as_ref() {
326            config.general.database_url = connection_string.clone();
327        }
328
329        let mock_dir = MockDataDir::new(config, Some(Keypair::random()))?;
330        self.testnet.create_homeserver_app_with_mock(mock_dir).await
331    }
332
333    /// Create a new pubky client builder.
334    pub fn client_builder(&self) -> pubky::PubkyHttpClientBuilder {
335        self.testnet.client_builder()
336    }
337
338    /// Creates a [`pubky::PubkyHttpClient`] pre-configured to use this test network.
339    pub fn client(&self) -> Result<pubky::PubkyHttpClient, pubky::BuildError> {
340        self.testnet.client()
341    }
342
343    /// Creates a [`pubky::Pubky`] SDK facade pre-configured to use this test network.
344    ///
345    /// This is a convenience method that builds a client from `Self::client_builder`.
346    pub fn sdk(&self) -> Result<Pubky, pubky::BuildError> {
347        self.testnet.sdk()
348    }
349
350    /// Create a new pkarr client builder.
351    pub fn pkarr_client_builder(&self) -> pkarr::ClientBuilder {
352        self.testnet.pkarr_client_builder()
353    }
354
355    /// Get the homeserver in the testnet.
356    pub fn homeserver_app(&self) -> &pubky_homeserver::HomeserverApp {
357        self.testnet
358            .homeservers
359            .first()
360            .expect("homeservers should be non-empty")
361    }
362
363    /// Get the http relay in the testnet.
364    pub fn http_relay(&self) -> &HttpRelay {
365        self.testnet
366            .http_relays
367            .first()
368            .expect("no http relay configured - use .with_http_relay() when building")
369    }
370}
371
372#[cfg(test)]
373mod test {
374    use super::*;
375
376    /// Test that two testnets can be run in a row.
377    /// This is to prevent the case where the testnet is not cleaned up properly.
378    /// For example, if the port is not released after the testnet is stopped.
379    #[tokio::test]
380    async fn test_two_testnet_in_a_row() {
381        {
382            let _ = EphemeralTestnet::builder().build().await.unwrap();
383        }
384
385        {
386            let _ = EphemeralTestnet::builder().build().await.unwrap();
387        }
388    }
389
390    #[tokio::test]
391    async fn test_homeserver_with_random_keypair() {
392        // Start with just DHT + http relay, no homeserver
393        let mut testnet = Testnet::new().await.unwrap();
394        testnet.create_http_relay().await.unwrap();
395        let mut network = EphemeralTestnet {
396            testnet,
397            #[cfg(feature = "docker-postgres")]
398            docker_postgres: None,
399        };
400        assert!(network.testnet.homeservers.is_empty());
401
402        let _ = network.create_random_homeserver().await.unwrap();
403        let _ = network.create_random_homeserver().await.unwrap();
404        assert!(network.testnet.homeservers.len() == 2);
405
406        // The two newly created homeservers must have distinct public keys.
407        assert_ne!(
408            network.testnet.homeservers[0].public_key(),
409            network.testnet.homeservers[1].public_key()
410        );
411    }
412
413    #[tokio::test]
414    async fn test_builder_default() {
415        // Verify builder creates homeserver with minimal config (admin disabled)
416        let network = EphemeralTestnet::builder().build().await.unwrap();
417        let homeserver = network.homeserver_app();
418
419        // The builder should use minimal_test_config() by default (admin disabled)
420        assert!(
421            homeserver.admin_server().is_none(),
422            "Builder should use minimal config with admin disabled by default"
423        );
424        assert!(
425            homeserver.metrics_server().is_none(),
426            "Builder should use minimal config with metrics disabled by default"
427        );
428    }
429
430    #[tokio::test]
431    async fn test_builder_with_custom_config() {
432        // Verify custom config is used (e.g., metrics enabled)
433        let mut config = ConfigToml::minimal_test_config();
434        config.metrics.enabled = true;
435
436        let network = EphemeralTestnet::builder()
437            .config(config)
438            .build()
439            .await
440            .unwrap();
441
442        let homeserver = network.homeserver_app();
443        assert!(
444            homeserver.metrics_server().is_some(),
445            "Custom config should enable metrics"
446        );
447        assert!(
448            homeserver.admin_server().is_none(),
449            "Custom config should keep admin disabled"
450        );
451    }
452
453    #[tokio::test]
454    async fn test_builder_with_custom_keypair() {
455        // Verify custom keypair is used
456        let keypair = Keypair::random();
457        let expected_public_key = keypair.public_key();
458
459        let network = EphemeralTestnet::builder()
460            .keypair(keypair)
461            .build()
462            .await
463            .unwrap();
464
465        let homeserver = network.homeserver_app();
466        assert_eq!(
467            homeserver.public_key(),
468            expected_public_key,
469            "Custom keypair should be used"
470        );
471    }
472}