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}