Skip to main content

ferro_rs/database/
testing.rs

1//! Testing utilities for database operations
2//!
3//! Provides `TestDatabase` for setting up isolated test environments with
4//! in-memory SQLite databases and automatic migration support.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use ferro_rs::test_database;
10//!
11//! #[tokio::test]
12//! async fn test_create_user() {
13//!     let db = test_database!();
14//!
15//!     // Your test code here - actions using DB::connection()
16//!     // will automatically use this test database
17//! }
18//! ```
19
20use sea_orm::DatabaseConnection;
21use sea_orm_migration::MigratorTrait;
22
23use super::config::DatabaseConfig;
24use super::connection::DbConnection;
25use crate::container::testing::{TestContainer, TestContainerGuard};
26use crate::error::FrameworkError;
27
28/// Monotonic counter giving each `TestDatabase::fresh()` a uniquely-named
29/// shared-cache in-memory database (cross-test isolation).
30static TEST_DB_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
31
32/// Test database wrapper that provides isolated database environments
33///
34/// Each `TestDatabase` creates a fresh in-memory SQLite database with
35/// migrations applied. The database is automatically registered in the
36/// test container, so any code using `DB::connection()` or `#[inject] db: Database`
37/// will receive this test database.
38///
39/// When the `TestDatabase` is dropped, the test container is cleared,
40/// ensuring complete isolation between tests.
41///
42/// # Example
43///
44/// ```rust,ignore
45/// use ferro_rs::testing::TestDatabase;
46/// use ferro_rs::migrations::Migrator;
47///
48/// #[tokio::test]
49/// async fn test_user_creation() {
50///     let db = TestDatabase::fresh::<Migrator>().await.unwrap();
51///
52///     // Actions using DB::connection() automatically get this test database
53///     let action = CreateUserAction::new();
54///     let user = action.execute("test@example.com").await.unwrap();
55///
56///     // Query directly using db.conn()
57///     let found = users::Entity::find_by_id(user.id)
58///         .one(db.conn())
59///         .await
60///         .unwrap();
61///     assert!(found.is_some());
62/// }
63/// ```
64pub struct TestDatabase {
65    conn: DbConnection,
66    _guard: TestContainerGuard,
67}
68
69impl TestDatabase {
70    /// Create a fresh test database with migrations applied
71    ///
72    /// This creates an in-memory SQLite database, runs all migrations,
73    /// and registers the connection in the test container.
74    ///
75    /// # Type Parameters
76    ///
77    /// * `M` - The migrator type implementing `MigratorTrait`. Typically
78    ///   this is `crate::migrations::Migrator` from your application.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if:
83    /// - Database connection fails
84    /// - Migration execution fails
85    ///
86    /// # Example
87    ///
88    /// ```rust,ignore
89    /// use ferro_rs::testing::TestDatabase;
90    /// use ferro_rs::migrations::Migrator;
91    ///
92    /// #[tokio::test]
93    /// async fn test_example() {
94    ///     let db = TestDatabase::fresh::<Migrator>().await.unwrap();
95    ///     // ...
96    /// }
97    /// ```
98    pub async fn fresh<M: MigratorTrait>() -> Result<Self, FrameworkError> {
99        // 1. Create test container guard for isolation
100        let guard = TestContainer::fake();
101
102        // 2. Create in-memory SQLite database.
103        //
104        // Shared-cache, uniquely-named :memory: DB with a multi-connection pool
105        // (mirrors a production pool). A plain single-connection `sqlite::memory:`
106        // pool deadlocks any code that holds an open transaction on the one
107        // connection and then acquires a fresh `DB::connection()` for a nested
108        // query (e.g. a webhook handler whose loader reads on a separate
109        // connection) — the second acquire blocks until the sqlx timeout.
110        // `cache=shared` makes every pooled connection see the same database;
111        // the unique name per `fresh()` keeps cross-test isolation; the unique
112        // name + min_connections(1) keeps the DB alive (a shared-cache :memory:
113        // DB is dropped when its last connection closes).
114        let seq = TEST_DB_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
115        let url = format!("sqlite:file:ferro_testdb_{seq}?mode=memory&cache=shared");
116        let config = DatabaseConfig::builder()
117            .url(url)
118            .max_connections(8)
119            .min_connections(1)
120            .logging(false)
121            .build();
122
123        let conn = DbConnection::connect(&config).await?;
124
125        // 3. Run migrations
126        M::up(conn.inner(), None)
127            .await
128            .map_err(|e| FrameworkError::database(format!("Migration failed: {e}")))?;
129
130        // 4. Register in TestContainer - this is the key integration!
131        // Any code calling DB::connection() or App::resolve::<DbConnection>()
132        // will now get this test database
133        TestContainer::singleton(conn.clone());
134
135        Ok(Self {
136            conn,
137            _guard: guard,
138        })
139    }
140
141    /// Get a reference to the underlying database connection
142    ///
143    /// Use this when you need to execute queries directly in your tests.
144    ///
145    /// # Example
146    ///
147    /// ```rust,ignore
148    /// let db = test_database!();
149    /// let users = users::Entity::find().all(db.conn()).await?;
150    /// ```
151    pub fn conn(&self) -> &DatabaseConnection {
152        self.conn.inner()
153    }
154
155    /// Get the `DbConnection` wrapper
156    ///
157    /// Use this when you need the full `DbConnection` type.
158    pub fn db(&self) -> &DbConnection {
159        &self.conn
160    }
161}
162
163/// Create a test database with default migrator
164///
165/// This macro creates a `TestDatabase` using `crate::migrations::Migrator` as the
166/// default migrator. This follows the Ferro convention where migrations are defined
167/// in `src/migrations/mod.rs`.
168///
169/// # Example
170///
171/// ```rust,ignore
172/// use ferro_rs::test_database;
173///
174/// #[tokio::test]
175/// async fn test_user_creation() {
176///     let db = test_database!();
177///
178///     let action = CreateUserAction::new();
179///     let user = action.execute("test@example.com").await.unwrap();
180///     assert!(user.id > 0);
181/// }
182/// ```
183///
184/// # With Custom Migrator
185///
186/// ```rust,ignore
187/// let db = test_database!(my_crate::CustomMigrator);
188/// ```
189#[macro_export]
190#[allow(clippy::crate_in_macro_def)]
191macro_rules! test_database {
192    () => {
193        $crate::testing::TestDatabase::fresh::<crate::migrations::Migrator>()
194            .await
195            .expect("Failed to set up test database")
196    };
197    ($migrator:ty) => {
198        $crate::testing::TestDatabase::fresh::<$migrator>()
199            .await
200            .expect("Failed to set up test database")
201    };
202}
203
204#[cfg(test)]
205mod fresh_pool_tests {
206    use super::TestDatabase;
207    use crate::database::DB;
208    use sea_orm::{ConnectionTrait, DatabaseBackend, Statement, TransactionTrait};
209    use sea_orm_migration::{MigrationTrait, MigratorTrait};
210
211    /// Empty migrator — these tests build their own tables via raw SQL.
212    struct NoopMigrator;
213    impl MigratorTrait for NoopMigrator {
214        fn migrations() -> Vec<Box<dyn MigrationTrait>> {
215            vec![]
216        }
217    }
218
219    /// Regression: a nested `DB::connection()` query MUST succeed while an outer
220    /// transaction is open. Production runs a multi-connection pool, so a handler
221    /// that holds a txn on one connection (writing table A) and reads a *different*
222    /// table on another connection works fine; the old single-connection
223    /// `sqlite::memory:` test pool deadlocked the second acquire until the sqlx
224    /// timeout. Shared-cache makes the second connection see data committed before
225    /// the txn. (Reads target a different table than the txn writes — exactly the
226    /// real webhook flow: the txn writes `payment_intents`, the loader reads
227    /// `orders` — so there is no SQLite table-lock contention.)
228    #[tokio::test]
229    async fn nested_connection_during_open_txn_does_not_deadlock() {
230        let _db = TestDatabase::fresh::<NoopMigrator>().await.unwrap();
231        let backend = DatabaseBackend::Sqlite;
232
233        let c = DB::connection().unwrap();
234        c.inner()
235            .execute(Statement::from_string(
236                backend,
237                "CREATE TABLE outer_t (id INTEGER PRIMARY KEY)",
238            ))
239            .await
240            .unwrap();
241        c.inner()
242            .execute(Statement::from_string(
243                backend,
244                "CREATE TABLE other_t (id INTEGER PRIMARY KEY, v TEXT)",
245            ))
246            .await
247            .unwrap();
248        c.inner()
249            .execute(Statement::from_string(
250                backend,
251                "INSERT INTO other_t (id, v) VALUES (1, 'committed')",
252            ))
253            .await
254            .unwrap();
255
256        // Open a write transaction on `outer_t` (mirrors the handler writing
257        // payment_intents).
258        let txn = DB::connection()
259            .unwrap()
260            .inner()
261            .clone()
262            .begin()
263            .await
264            .unwrap();
265        txn.execute(Statement::from_string(
266            backend,
267            "INSERT INTO outer_t (id) VALUES (99)",
268        ))
269        .await
270        .unwrap();
271
272        // While the txn is open, a nested connection reads a DIFFERENT table
273        // (mirrors the loader reading `orders`). Must NOT deadlock on acquire and
274        // MUST see the committed row. A single-connection pool blocks here until
275        // the acquire timeout.
276        let nested = DB::connection().unwrap();
277        let rows = nested
278            .inner()
279            .query_all(Statement::from_string(
280                backend,
281                "SELECT id FROM other_t WHERE id = 1",
282            ))
283            .await
284            .unwrap();
285        assert_eq!(
286            rows.len(),
287            1,
288            "nested connection must read committed data without deadlocking"
289        );
290
291        txn.commit().await.unwrap();
292    }
293
294    /// Each `fresh()` must yield an isolated database (unique shared-cache name).
295    #[tokio::test]
296    async fn fresh_databases_are_isolated() {
297        let backend = DatabaseBackend::Sqlite;
298        {
299            let _db1 = TestDatabase::fresh::<NoopMigrator>().await.unwrap();
300            DB::connection()
301                .unwrap()
302                .inner()
303                .execute(Statement::from_string(
304                    backend,
305                    "CREATE TABLE iso_t (id INTEGER PRIMARY KEY)",
306                ))
307                .await
308                .unwrap();
309        }
310        // A new fresh DB must not see the previous DB's tables.
311        let _db2 = TestDatabase::fresh::<NoopMigrator>().await.unwrap();
312        let res = DB::connection()
313            .unwrap()
314            .inner()
315            .query_all(Statement::from_string(
316                backend,
317                "SELECT name FROM sqlite_master WHERE type='table' AND name='iso_t'",
318            ))
319            .await
320            .unwrap();
321        assert!(
322            res.is_empty(),
323            "a fresh test DB must not see a prior DB's tables (isolation)"
324        );
325    }
326}