forge_core/testing/
db.rs

1//! Database provisioning for tests.
2//!
3//! Provides PostgreSQL access for integration tests. Database configuration
4//! options:
5//! 1. Pass a URL directly via `from_url()`
6//! 2. Use `from_env()` to explicitly read from TEST_DATABASE_URL
7//! 3. Use `embedded()` for automatic embedded PostgreSQL (requires `embedded-db` feature)
8//!
9//! This design prevents accidental use of production databases. The .env file
10//! DATABASE_URL is NEVER automatically read.
11
12use sqlx::PgPool;
13
14use crate::error::{ForgeError, Result};
15
16#[cfg(feature = "embedded-db")]
17use tokio::sync::OnceCell;
18
19#[cfg(feature = "embedded-db")]
20static EMBEDDED_PG: OnceCell<postgresql_embedded::PostgreSQL> = OnceCell::const_new();
21
22/// Database access for tests.
23///
24/// Test database configuration is intentionally explicit to prevent
25/// accidental use of production databases.
26///
27/// # Examples
28///
29/// ```ignore
30/// // Option 1: Embedded Postgres (requires embedded-db feature)
31/// // Run with: cargo test --features embedded-db
32/// let db = TestDatabase::embedded().await?;
33///
34/// // Option 2: Explicit URL
35/// let db = TestDatabase::from_url("postgres://localhost/test_db").await?;
36///
37/// // Option 3: From TEST_DATABASE_URL env var
38/// let db = TestDatabase::from_env().await?;
39/// ```
40pub struct TestDatabase {
41    pool: PgPool,
42    url: String,
43}
44
45impl TestDatabase {
46    /// Connect to database at the given URL.
47    ///
48    /// Use this for explicit database configuration in tests.
49    pub async fn from_url(url: &str) -> Result<Self> {
50        let pool = sqlx::postgres::PgPoolOptions::new()
51            .max_connections(10)
52            .connect(url)
53            .await
54            .map_err(ForgeError::Sql)?;
55
56        Ok(Self {
57            pool,
58            url: url.to_string(),
59        })
60    }
61
62    /// Connect using TEST_DATABASE_URL environment variable.
63    ///
64    /// Note: This reads TEST_DATABASE_URL (not DATABASE_URL) to prevent
65    /// accidental use of production databases in tests.
66    pub async fn from_env() -> Result<Self> {
67        let url = std::env::var("TEST_DATABASE_URL").map_err(|_| {
68            ForgeError::Database(
69                "TEST_DATABASE_URL not set. Set it explicitly for database tests.".to_string(),
70            )
71        })?;
72        Self::from_url(&url).await
73    }
74
75    /// Start an embedded PostgreSQL instance.
76    ///
77    /// Downloads and starts a real PostgreSQL instance automatically.
78    /// Requires the `embedded-db` feature: `cargo test --features embedded-db`
79    #[cfg(feature = "embedded-db")]
80    pub async fn embedded() -> Result<Self> {
81        let pg = EMBEDDED_PG
82            .get_or_try_init(|| async {
83                let mut pg = postgresql_embedded::PostgreSQL::default();
84                pg.setup().await.map_err(|e| {
85                    ForgeError::Database(format!("Failed to setup embedded Postgres: {}", e))
86                })?;
87                pg.start().await.map_err(|e| {
88                    ForgeError::Database(format!("Failed to start embedded Postgres: {}", e))
89                })?;
90                Ok::<_, ForgeError>(pg)
91            })
92            .await?;
93
94        let url = pg.settings().url("postgres");
95        Self::from_url(&url).await
96    }
97
98    /// Get the connection pool.
99    pub fn pool(&self) -> &PgPool {
100        &self.pool
101    }
102
103    /// Get the database URL.
104    pub fn url(&self) -> &str {
105        &self.url
106    }
107
108    /// Run raw SQL to set up test data or schema.
109    pub async fn execute(&self, sql: &str) -> Result<()> {
110        sqlx::query(sql)
111            .execute(&self.pool)
112            .await
113            .map_err(ForgeError::Sql)?;
114        Ok(())
115    }
116
117    /// Creates a dedicated database for a single test, providing full isolation.
118    ///
119    /// Each call creates a new database with a unique name. Use this when tests
120    /// modify data and could interfere with each other.
121    pub async fn isolated(&self, test_name: &str) -> Result<IsolatedTestDb> {
122        let base_url = self.url.clone();
123        // UUID suffix prevents collisions when tests run in parallel
124        let db_name = format!(
125            "forge_test_{}_{}",
126            sanitize_db_name(test_name),
127            uuid::Uuid::new_v4().simple()
128        );
129
130        // Connect to default database to create the test database
131        let pool = sqlx::postgres::PgPoolOptions::new()
132            .max_connections(1)
133            .connect(&base_url)
134            .await
135            .map_err(ForgeError::Sql)?;
136
137        // Double-quoted identifier handles special characters in generated name
138        sqlx::query(&format!("CREATE DATABASE \"{}\"", db_name))
139            .execute(&pool)
140            .await
141            .map_err(ForgeError::Sql)?;
142
143        // Build URL for the new database by replacing the database name component
144        let test_url = replace_db_name(&base_url, &db_name);
145
146        let test_pool = sqlx::postgres::PgPoolOptions::new()
147            .max_connections(5)
148            .connect(&test_url)
149            .await
150            .map_err(ForgeError::Sql)?;
151
152        Ok(IsolatedTestDb {
153            pool: test_pool,
154            db_name,
155            base_url,
156        })
157    }
158}
159
160/// A test database that exists for the lifetime of a single test.
161///
162/// The database is automatically created on construction. Cleanup happens
163/// when `cleanup()` is called or when the database is reused in subsequent
164/// test runs (orphaned databases are cleaned up automatically).
165pub struct IsolatedTestDb {
166    pool: PgPool,
167    db_name: String,
168    base_url: String,
169}
170
171impl IsolatedTestDb {
172    /// Get the connection pool for this isolated database.
173    pub fn pool(&self) -> &PgPool {
174        &self.pool
175    }
176
177    /// Get the database name.
178    pub fn db_name(&self) -> &str {
179        &self.db_name
180    }
181
182    /// Run raw SQL to set up test data or schema.
183    pub async fn execute(&self, sql: &str) -> Result<()> {
184        sqlx::query(sql)
185            .execute(&self.pool)
186            .await
187            .map_err(ForgeError::Sql)?;
188        Ok(())
189    }
190
191    /// Cleanup the test database by dropping it.
192    ///
193    /// Call this at the end of your test if you want immediate cleanup.
194    /// Otherwise, orphaned databases will be cleaned up on subsequent test runs.
195    pub async fn cleanup(self) -> Result<()> {
196        // Close all connections first
197        self.pool.close().await;
198
199        // Connect to default database to drop the test database
200        let pool = sqlx::postgres::PgPoolOptions::new()
201            .max_connections(1)
202            .connect(&self.base_url)
203            .await
204            .map_err(ForgeError::Sql)?;
205
206        // Force disconnect other connections and drop
207        let _ = sqlx::query(&format!(
208            "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{}'",
209            self.db_name
210        ))
211        .execute(&pool)
212        .await;
213
214        sqlx::query(&format!("DROP DATABASE IF EXISTS \"{}\"", self.db_name))
215            .execute(&pool)
216            .await
217            .map_err(ForgeError::Sql)?;
218
219        Ok(())
220    }
221}
222
223/// Sanitize a test name for use in a database name.
224fn sanitize_db_name(name: &str) -> String {
225    name.chars()
226        .map(|c| if c.is_alphanumeric() { c } else { '_' })
227        .take(32)
228        .collect()
229}
230
231/// Replace the database name in a connection URL.
232fn replace_db_name(url: &str, new_db: &str) -> String {
233    // Handle both postgres://.../ and postgres://...? formats
234    if let Some(idx) = url.rfind('/') {
235        let base = &url[..=idx];
236        // Check if there are query params
237        if let Some(query_idx) = url[idx + 1..].find('?') {
238            let query = &url[idx + 1 + query_idx..];
239            format!("{}{}{}", base, new_db, query)
240        } else {
241            format!("{}{}", base, new_db)
242        }
243    } else {
244        format!("{}/{}", url, new_db)
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn test_sanitize_db_name() {
254        assert_eq!(sanitize_db_name("my_test"), "my_test");
255        assert_eq!(sanitize_db_name("my-test"), "my_test");
256        assert_eq!(sanitize_db_name("my test"), "my_test");
257        assert_eq!(sanitize_db_name("test::function"), "test__function");
258    }
259
260    #[test]
261    fn test_replace_db_name() {
262        assert_eq!(
263            replace_db_name("postgres://localhost/olddb", "newdb"),
264            "postgres://localhost/newdb"
265        );
266        assert_eq!(
267            replace_db_name("postgres://user:pass@localhost:5432/olddb", "newdb"),
268            "postgres://user:pass@localhost:5432/newdb"
269        );
270        assert_eq!(
271            replace_db_name("postgres://localhost/olddb?sslmode=disable", "newdb"),
272            "postgres://localhost/newdb?sslmode=disable"
273        );
274    }
275}