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        config.general.database_url = testnet
199            .postgres_connection_string
200            .clone()
201            .or(config.general.database_url);
202
203        let keypair = self
204            .homeserver_keypair
205            .unwrap_or_else(crate::common::testnet_keypair);
206        let mock_dir = MockDataDir::new(config, Some(keypair))?;
207        testnet.create_homeserver_app_with_mock(mock_dir).await?;
208
209        Ok(EphemeralTestnet {
210            testnet,
211            #[cfg(feature = "docker-postgres")]
212            docker_postgres,
213        })
214    }
215}
216
217impl Default for EphemeralTestnetBuilder {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223impl EphemeralTestnet {
224    /// Create a new builder for configuring the testnet.
225    ///
226    /// This is the recommended way to create a testnet with custom configuration.
227    ///
228    /// # Example
229    /// ```ignore
230    /// let testnet = EphemeralTestnet::builder()
231    ///     .config(ConfigToml::default_test_config())
232    ///     .keypair(Keypair::random())
233    ///     .build()
234    ///     .await?;
235    /// ```
236    pub fn builder() -> EphemeralTestnetBuilder {
237        EphemeralTestnetBuilder::new()
238    }
239
240    /// Run a new simple testnet with full config (admin enabled).
241    ///
242    /// # Deprecated
243    /// Use [`Self::builder()`] for explicit configuration control.
244    /// This method uses [`ConfigToml::default_test_config()`] which enables the admin server.
245    #[deprecated(
246        since = "0.5.0",
247        note = "Use EphemeralTestnet::builder().config(ConfigToml::default_test_config()).build() for explicit behavior"
248    )]
249    pub async fn start() -> anyhow::Result<Self> {
250        let mut testnet = Testnet::new().await?;
251        testnet.create_http_relay().await?;
252        testnet.create_homeserver().await?;
253        Ok(Self {
254            testnet,
255            #[cfg(feature = "docker-postgres")]
256            docker_postgres: None,
257        })
258    }
259
260    /// Run a new simple testnet with custom postgres and full config (admin enabled).
261    ///
262    /// # Deprecated
263    /// Use [`Self::builder()`] with `.postgres()` for explicit configuration control.
264    #[deprecated(
265        since = "0.5.0",
266        note = "Use EphemeralTestnet::builder().postgres(...).config(ConfigToml::default_test_config()).build() instead"
267    )]
268    pub async fn start_with_custom_postgres(
269        postgres_connection_string: ConnectionString,
270    ) -> anyhow::Result<Self> {
271        let mut testnet = Testnet::new_with_custom_postgres(postgres_connection_string).await?;
272        testnet.create_http_relay().await?;
273        testnet.create_homeserver().await?;
274        Ok(Self {
275            testnet,
276            #[cfg(feature = "docker-postgres")]
277            docker_postgres: None,
278        })
279    }
280
281    /// Run a new simple testnet with custom postgres but no homeserver (minimal setup).
282    ///
283    /// # Deprecated
284    /// Use [`Testnet`] directly for fine-grained control over component creation.
285    #[deprecated(
286        since = "0.5.0",
287        note = "Use Testnet::new_with_custom_postgres() and create_http_relay() for fine-grained control"
288    )]
289    pub async fn start_minimal_with_custom_postgres(
290        postgres_connection_string: ConnectionString,
291    ) -> anyhow::Result<Self> {
292        let mut me = Self {
293            testnet: Testnet::new_with_custom_postgres(postgres_connection_string).await?,
294            #[cfg(feature = "docker-postgres")]
295            docker_postgres: None,
296        };
297        me.testnet.create_http_relay().await?;
298        Ok(me)
299    }
300
301    /// Run a new simple testnet network with a minimal setup (no homeserver).
302    ///
303    /// # Deprecated
304    /// Use [`Testnet`] directly for fine-grained control over component creation.
305    #[deprecated(
306        since = "0.5.0",
307        note = "Use Testnet::new() and create_http_relay() for fine-grained control"
308    )]
309    pub async fn start_minimal() -> anyhow::Result<Self> {
310        let mut me = Self {
311            testnet: Testnet::new().await?,
312            #[cfg(feature = "docker-postgres")]
313            docker_postgres: None,
314        };
315        me.testnet.create_http_relay().await?;
316        Ok(me)
317    }
318
319    /// Create an additional homeserver with a random keypair.
320    pub async fn create_random_homeserver(&mut self) -> anyhow::Result<&HomeserverApp> {
321        self.create_random_homeserver_with_config(None).await
322    }
323
324    /// Create an additional homeserver with a random keypair and custom config.
325    /// Uses minimal_test_config() by default (admin/metrics disabled).
326    pub async fn create_random_homeserver_with_config(
327        &mut self,
328        config: Option<ConfigToml>,
329    ) -> anyhow::Result<&HomeserverApp> {
330        let mut config = config.unwrap_or_else(ConfigToml::minimal_test_config);
331
332        config.general.database_url = self
333            .testnet
334            .postgres_connection_string
335            .clone()
336            .or(config.general.database_url);
337
338        let mock_dir = MockDataDir::new(config, Some(Keypair::random()))?;
339        self.testnet.create_homeserver_app_with_mock(mock_dir).await
340    }
341
342    /// Create a new pubky client builder.
343    pub fn client_builder(&self) -> pubky::PubkyHttpClientBuilder {
344        self.testnet.client_builder()
345    }
346
347    /// Creates a [`pubky::PubkyHttpClient`] pre-configured to use this test network.
348    pub fn client(&self) -> Result<pubky::PubkyHttpClient, pubky::BuildError> {
349        self.testnet.client()
350    }
351
352    /// Creates a [`pubky::Pubky`] SDK facade pre-configured to use this test network.
353    ///
354    /// This is a convenience method that builds a client from `Self::client_builder`.
355    pub fn sdk(&self) -> Result<Pubky, pubky::BuildError> {
356        self.testnet.sdk()
357    }
358
359    /// Create a new pkarr client builder.
360    pub fn pkarr_client_builder(&self) -> pkarr::ClientBuilder {
361        self.testnet.pkarr_client_builder()
362    }
363
364    /// Get the homeserver in the testnet.
365    pub fn homeserver_app(&self) -> &pubky_homeserver::HomeserverApp {
366        self.testnet
367            .homeservers
368            .first()
369            .expect("homeservers should be non-empty")
370    }
371
372    /// Get the http relay in the testnet.
373    pub fn http_relay(&self) -> &HttpRelay {
374        self.testnet
375            .http_relays
376            .first()
377            .expect("no http relay configured - use .with_http_relay() when building")
378    }
379}
380
381#[cfg(test)]
382mod test {
383    use super::*;
384
385    /// Test that two testnets can be run in a row.
386    /// This is to prevent the case where the testnet is not cleaned up properly.
387    /// For example, if the port is not released after the testnet is stopped.
388    #[tokio::test]
389    #[crate::test]
390    async fn test_two_testnet_in_a_row() {
391        {
392            let _ = EphemeralTestnet::builder().build().await.unwrap();
393        }
394
395        {
396            let _ = EphemeralTestnet::builder().build().await.unwrap();
397        }
398    }
399
400    #[tokio::test]
401    #[crate::test]
402    async fn test_homeserver_with_random_keypair() {
403        // Start with just DHT + http relay, no homeserver
404        let mut testnet = Testnet::new().await.unwrap();
405        testnet.create_http_relay().await.unwrap();
406        let mut network = EphemeralTestnet {
407            testnet,
408            #[cfg(feature = "docker-postgres")]
409            docker_postgres: None,
410        };
411        assert!(network.testnet.homeservers.is_empty());
412
413        let _ = network.create_random_homeserver().await.unwrap();
414        let _ = network.create_random_homeserver().await.unwrap();
415        assert!(network.testnet.homeservers.len() == 2);
416
417        // The two newly created homeservers must have distinct public keys.
418        assert_ne!(
419            network.testnet.homeservers[0].public_key(),
420            network.testnet.homeservers[1].public_key()
421        );
422    }
423
424    #[tokio::test]
425    #[crate::test]
426    async fn test_builder_default() {
427        // Verify builder creates homeserver with minimal config (admin disabled)
428        let network = EphemeralTestnet::builder().build().await.unwrap();
429        let homeserver = network.homeserver_app();
430
431        // The builder should use minimal_test_config() by default (admin disabled)
432        assert!(
433            homeserver.admin_server().is_none(),
434            "Builder should use minimal config with admin disabled by default"
435        );
436        assert!(
437            homeserver.metrics_server().is_none(),
438            "Builder should use minimal config with metrics disabled by default"
439        );
440    }
441
442    #[tokio::test]
443    #[crate::test]
444    async fn test_builder_with_custom_config() {
445        // Verify custom config is used (e.g., metrics enabled)
446        let mut config = ConfigToml::minimal_test_config();
447        config.metrics.enabled = true;
448
449        let network = EphemeralTestnet::builder()
450            .config(config)
451            .build()
452            .await
453            .unwrap();
454
455        let homeserver = network.homeserver_app();
456        assert!(
457            homeserver.metrics_server().is_some(),
458            "Custom config should enable metrics"
459        );
460        assert!(
461            homeserver.admin_server().is_none(),
462            "Custom config should keep admin disabled"
463        );
464    }
465
466    #[tokio::test]
467    #[crate::test]
468    async fn test_builder_with_custom_keypair() {
469        // Verify custom keypair is used
470        let keypair = Keypair::random();
471        let expected_public_key = keypair.public_key();
472
473        let network = EphemeralTestnet::builder()
474            .keypair(keypair)
475            .build()
476            .await
477            .unwrap();
478
479        let homeserver = network.homeserver_app();
480        assert_eq!(
481            homeserver.public_key(),
482            expected_public_key,
483            "Custom keypair should be used"
484        );
485    }
486}