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