Skip to main content

bsql_core/
test_support.rs

1//! Test infrastructure for `#[bsql::test]`.
2//!
3//! Creates isolated PostgreSQL schemas per test for parallel execution.
4//! Fixtures (SQL files) are applied to the schema before the test runs.
5//! Schema is dropped after the test -- even on panic.
6
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use bsql_driver_postgres::{Config, Connection};
10
11use crate::error::{BsqlError, ConnectError};
12use crate::pool::Pool;
13
14static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
15
16/// Test context holding the pool and cleanup info.
17/// Drops the schema on cleanup.
18pub struct TestContext {
19    /// The connection pool, scoped to the isolated test schema.
20    pub pool: Pool,
21    schema_name: String,
22    db_url: String,
23}
24
25impl std::fmt::Debug for TestContext {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        f.debug_struct("TestContext")
28            .field("schema", &self.schema_name)
29            .finish()
30    }
31}
32
33impl Drop for TestContext {
34    fn drop(&mut self) {
35        // Fresh connection for cleanup (pool connection may be broken after panic).
36        // Errors are intentionally ignored -- we are in a destructor.
37        if let Ok(config) = Config::from_url(&self.db_url) {
38            if let Ok(mut conn) = Connection::connect(&config) {
39                let _ = conn.simple_query(&format!(
40                    "DROP SCHEMA IF EXISTS \"{}\" CASCADE",
41                    self.schema_name
42                ));
43            }
44        }
45    }
46}
47
48/// Set up an isolated test schema with fixtures.
49///
50/// Called by generated `#[bsql::test]` code. Not intended for direct use.
51///
52/// `fixtures_sql` contains compile-time embedded SQL strings from fixture files.
53pub async fn setup_test_schema(fixtures_sql: &[&str]) -> Result<TestContext, BsqlError> {
54    let db_url = std::env::var("BSQL_DATABASE_URL")
55        .or_else(|_| std::env::var("DATABASE_URL"))
56        .map_err(|_| {
57            ConnectError::create("BSQL_DATABASE_URL or DATABASE_URL must be set for #[bsql::test]")
58        })?;
59
60    let schema_name = format!(
61        "__bsql_test_{}_{}",
62        std::process::id(),
63        TEST_COUNTER.fetch_add(1, Ordering::Relaxed),
64    );
65
66    // Setup connection: create schema, apply fixtures
67    let config = Config::from_url(&db_url)
68        .map_err(|e| ConnectError::create(format!("invalid database URL: {e}")))?;
69    let mut conn = Connection::connect(&config)
70        .map_err(|e| ConnectError::create(format!("connection failed: {e}")))?;
71
72    // Create isolated schema
73    conn.simple_query(&format!("CREATE SCHEMA \"{}\"", schema_name))
74        .map_err(|e| ConnectError::create(format!("failed to create test schema: {e}")))?;
75
76    // Set search_path to test schema (with public for extensions)
77    conn.simple_query(&format!("SET search_path TO \"{}\", public", schema_name))
78        .map_err(|e| ConnectError::create(format!("failed to set search_path: {e}")))?;
79
80    // Apply fixtures in order
81    for fixture_sql in fixtures_sql {
82        if !fixture_sql.trim().is_empty() {
83            conn.simple_query(fixture_sql)
84                .map_err(|e| ConnectError::create(format!("fixture failed: {e}")))?;
85        }
86    }
87
88    drop(conn); // Release setup connection
89
90    // Build pool. Connections are lazy, so we create the pool first,
91    // then immediately acquire one connection and set search_path on it.
92    let pool = Pool::connect(&db_url).await?;
93
94    // Acquire a connection and set search_path so all subsequent queries
95    // in this test run against the isolated schema.
96    pool.raw_execute(&format!("SET search_path TO \"{}\", public", schema_name))
97        .await?;
98
99    // Set warmup SQL so any *new* connections from this pool also get
100    // the correct search_path (the pool has max_size=10 by default,
101    // but for tests we typically only use 1 connection).
102    let warmup_sql = format!("SET search_path TO \"{}\", public", schema_name);
103    // set_warmup_sqls copies strings internally (into Box<str>), so &str
104    // only needs to live for the duration of this call. No leak needed.
105    pool.set_warmup_sqls([warmup_sql]);
106
107    Ok(TestContext {
108        pool,
109        schema_name,
110        db_url,
111    })
112}
113
114// ===========================================================================
115// SQLite test support
116// ===========================================================================
117
118/// SQLite test context — isolated temporary database file.
119///
120/// Created by [`setup_sqlite_test`]. The temporary file (plus WAL/SHM) is
121/// automatically deleted when the context is dropped.
122#[cfg(feature = "sqlite")]
123pub struct SqliteTestContext {
124    /// The SQLite connection pool for the test.
125    pub pool: crate::sqlite_pool::SqlitePool,
126    /// Path to the temporary database file (public for tests to inspect).
127    pub db_path: std::path::PathBuf,
128}
129
130#[cfg(feature = "sqlite")]
131impl Drop for SqliteTestContext {
132    fn drop(&mut self) {
133        // Close pool first to release the file lock.
134        self.pool.close();
135        // Delete the temp database file and any WAL/SHM sidecar files.
136        let _ = std::fs::remove_file(&self.db_path);
137        let _ = std::fs::remove_file(format!("{}-wal", self.db_path.display()));
138        let _ = std::fs::remove_file(format!("{}-shm", self.db_path.display()));
139    }
140}
141
142/// Set up an isolated SQLite test database with fixtures.
143///
144/// Called by generated `#[bsql::test]` code for SQLite tests.
145/// Not intended for direct use.
146///
147/// Each call creates a unique temporary file (PID + atomic counter).
148/// `fixtures_sql` contains compile-time embedded SQL strings from fixture files.
149#[cfg(feature = "sqlite")]
150pub fn setup_sqlite_test(
151    fixtures_sql: &[&str],
152) -> Result<SqliteTestContext, crate::error::BsqlError> {
153    use crate::error::ConnectError;
154
155    // Generate a unique temp file path.
156    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
157    let db_path = std::env::temp_dir().join(format!(
158        "bsql_test_{}_{}.db",
159        std::process::id(),
160        COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
161    ));
162
163    // Create pool (SqlitePool::connect is sync — no async runtime required).
164    let pool = crate::sqlite_pool::SqlitePool::connect(db_path.to_str().unwrap_or("bsql_test.db"))?;
165
166    // Apply fixtures in order.
167    for fixture_sql in fixtures_sql {
168        if !fixture_sql.trim().is_empty() {
169            pool.simple_exec(fixture_sql)
170                .map_err(|e| ConnectError::create(format!("SQLite fixture failed: {e}")))?;
171        }
172    }
173
174    Ok(SqliteTestContext { pool, db_path })
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use std::collections::HashSet;
181
182    // ---------------------------------------------------------------
183    // Schema lifecycle
184    // ---------------------------------------------------------------
185
186    #[test]
187    fn schema_name_is_unique() {
188        let name1 = format!(
189            "__bsql_test_{}_{}",
190            std::process::id(),
191            TEST_COUNTER.fetch_add(1, Ordering::Relaxed),
192        );
193        let name2 = format!(
194            "__bsql_test_{}_{}",
195            std::process::id(),
196            TEST_COUNTER.fetch_add(1, Ordering::Relaxed),
197        );
198        assert_ne!(name1, name2);
199    }
200
201    #[test]
202    fn schema_name_contains_pid() {
203        let name = format!(
204            "__bsql_test_{}_{}",
205            std::process::id(),
206            TEST_COUNTER.fetch_add(1, Ordering::Relaxed),
207        );
208        assert!(name.contains(&std::process::id().to_string()));
209    }
210
211    #[test]
212    fn schema_name_starts_with_prefix() {
213        let name = format!(
214            "__bsql_test_{}_{}",
215            std::process::id(),
216            TEST_COUNTER.fetch_add(1, Ordering::Relaxed),
217        );
218        assert!(name.starts_with("__bsql_test_"));
219    }
220
221    #[test]
222    fn schema_names_never_collide_100_sequential() {
223        let mut names = HashSet::new();
224        for _ in 0..100 {
225            let name = format!(
226                "__bsql_test_{}_{}",
227                std::process::id(),
228                TEST_COUNTER.fetch_add(1, Ordering::Relaxed),
229            );
230            assert!(names.insert(name.clone()), "duplicate schema name: {name}");
231        }
232        assert_eq!(names.len(), 100);
233    }
234
235    #[test]
236    fn schema_name_is_valid_sql_identifier() {
237        let name = format!(
238            "__bsql_test_{}_{}",
239            std::process::id(),
240            TEST_COUNTER.fetch_add(1, Ordering::Relaxed),
241        );
242        // Valid SQL identifier: starts with letter or underscore, then alphanumeric/underscore
243        assert!(
244            name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
245            "schema name contains invalid chars: {name}"
246        );
247        assert!(
248            name.starts_with('_') || name.starts_with(|c: char| c.is_ascii_alphabetic()),
249            "schema name must start with letter or underscore: {name}"
250        );
251    }
252
253    // ---------------------------------------------------------------
254    // Counter atomicity
255    // ---------------------------------------------------------------
256
257    #[test]
258    fn test_counter_is_monotonic() {
259        let a = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
260        let b = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
261        let c = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
262        assert!(a < b);
263        assert!(b < c);
264    }
265
266    #[test]
267    fn counter_increments_atomically_across_threads() {
268        use std::sync::Arc;
269        let results: Arc<std::sync::Mutex<Vec<u64>>> = Arc::new(std::sync::Mutex::new(Vec::new()));
270        let mut handles = Vec::new();
271        for _ in 0..10 {
272            let results = Arc::clone(&results);
273            handles.push(std::thread::spawn(move || {
274                for _ in 0..10 {
275                    let val = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
276                    results.lock().unwrap().push(val);
277                }
278            }));
279        }
280        for h in handles {
281            h.join().unwrap();
282        }
283        let mut vals = results.lock().unwrap().clone();
284        assert_eq!(vals.len(), 100, "expected 100 counter values");
285        // All values must be unique (no duplicates from racing threads)
286        let set: HashSet<u64> = vals.iter().copied().collect();
287        assert_eq!(
288            set.len(),
289            100,
290            "counter values must be unique across threads"
291        );
292        // Sorted values must be strictly increasing
293        vals.sort();
294        for window in vals.windows(2) {
295            assert!(window[0] < window[1], "counter must be strictly increasing");
296        }
297    }
298
299    // ---------------------------------------------------------------
300    // Concurrency — multiple TestContexts
301    // ---------------------------------------------------------------
302
303    #[test]
304    fn multiple_schema_names_created_simultaneously_are_different() {
305        // Simulate what happens when multiple tests call setup at the same instant
306        let names: Vec<String> = (0..50)
307            .map(|_| {
308                format!(
309                    "__bsql_test_{}_{}",
310                    std::process::id(),
311                    TEST_COUNTER.fetch_add(1, Ordering::Relaxed),
312                )
313            })
314            .collect();
315        let set: HashSet<&String> = names.iter().collect();
316        assert_eq!(set.len(), names.len(), "all schema names must be unique");
317    }
318
319    // ---------------------------------------------------------------
320    // Setup error paths
321    // ---------------------------------------------------------------
322
323    #[tokio::test]
324    async fn missing_db_url_returns_clear_error() {
325        // Temporarily unset both env vars (if set)
326        let orig_bsql = std::env::var("BSQL_DATABASE_URL").ok();
327        let orig_db = std::env::var("DATABASE_URL").ok();
328        std::env::remove_var("BSQL_DATABASE_URL");
329        std::env::remove_var("DATABASE_URL");
330
331        let result = setup_test_schema(&[]).await;
332        assert!(result.is_err());
333        let err = result.unwrap_err();
334        let msg = err.to_string();
335        assert!(
336            msg.contains("BSQL_DATABASE_URL") && msg.contains("DATABASE_URL"),
337            "error should mention both env vars, got: {msg}"
338        );
339
340        // Restore
341        if let Some(v) = orig_bsql {
342            std::env::set_var("BSQL_DATABASE_URL", v);
343        }
344        if let Some(v) = orig_db {
345            std::env::set_var("DATABASE_URL", v);
346        }
347    }
348
349    #[tokio::test]
350    async fn missing_bsql_database_url_falls_back_to_database_url() {
351        let orig_bsql = std::env::var("BSQL_DATABASE_URL").ok();
352        let orig_db = std::env::var("DATABASE_URL").ok();
353        std::env::remove_var("BSQL_DATABASE_URL");
354        // Set DATABASE_URL to something invalid so we get past env-check but fail on connect
355        std::env::set_var("DATABASE_URL", "not-a-url");
356
357        let result = setup_test_schema(&[]).await;
358        // Should fail on URL parse, not on missing env var
359        assert!(result.is_err());
360        let msg = result.unwrap_err().to_string();
361        assert!(
362            msg.contains("invalid database URL"),
363            "should fail on URL parse after falling back to DATABASE_URL, got: {msg}"
364        );
365
366        // Restore
367        std::env::remove_var("DATABASE_URL");
368        if let Some(v) = orig_bsql {
369            std::env::set_var("BSQL_DATABASE_URL", v);
370        }
371        if let Some(v) = orig_db {
372            std::env::set_var("DATABASE_URL", v);
373        }
374    }
375
376    #[tokio::test]
377    async fn invalid_db_url_returns_clear_error() {
378        let orig_bsql = std::env::var("BSQL_DATABASE_URL").ok();
379        let orig_db = std::env::var("DATABASE_URL").ok();
380        std::env::set_var("BSQL_DATABASE_URL", "not-a-valid-url");
381        std::env::remove_var("DATABASE_URL");
382
383        let result = setup_test_schema(&[]).await;
384        assert!(result.is_err());
385        let err = result.unwrap_err();
386        let msg = err.to_string();
387        assert!(
388            msg.contains("invalid database URL"),
389            "error should mention invalid URL, got: {msg}"
390        );
391
392        // Restore
393        std::env::remove_var("BSQL_DATABASE_URL");
394        if let Some(v) = orig_bsql {
395            std::env::set_var("BSQL_DATABASE_URL", v);
396        }
397        if let Some(v) = orig_db {
398            std::env::set_var("DATABASE_URL", v);
399        }
400    }
401
402    #[tokio::test]
403    async fn invalid_db_url_not_postgres_scheme() {
404        let orig_bsql = std::env::var("BSQL_DATABASE_URL").ok();
405        let orig_db = std::env::var("DATABASE_URL").ok();
406        std::env::set_var("BSQL_DATABASE_URL", "mysql://user:pass@localhost/db");
407        std::env::remove_var("DATABASE_URL");
408
409        let result = setup_test_schema(&[]).await;
410        assert!(result.is_err());
411        let msg = result.unwrap_err().to_string();
412        assert!(
413            msg.contains("invalid database URL"),
414            "non-postgres scheme should fail with clear error, got: {msg}"
415        );
416
417        std::env::remove_var("BSQL_DATABASE_URL");
418        if let Some(v) = orig_bsql {
419            std::env::set_var("BSQL_DATABASE_URL", v);
420        }
421        if let Some(v) = orig_db {
422            std::env::set_var("DATABASE_URL", v);
423        }
424    }
425
426    #[test]
427    fn connection_refused_unreachable_host() {
428        // Test the connection-refused path directly, bypassing env-var setup
429        // to avoid races with other concurrent async tests that manipulate env.
430        let url = "postgres://user:pass@127.0.0.1:1/testdb";
431        let config = Config::from_url(url).expect("URL should parse");
432        let conn_result = Connection::connect(&config);
433        assert!(conn_result.is_err(), "connection to port 1 should fail");
434        // Verify the error maps to a ConnectError with "connection failed" message
435        // (this is the exact error path that setup_test_schema takes)
436        let err = ConnectError::create(format!("connection failed: {}", conn_result.unwrap_err()));
437        let msg = err.to_string();
438        assert!(
439            msg.contains("connection failed"),
440            "unreachable host should produce 'connection failed' error, got: {msg}"
441        );
442    }
443
444    // ---------------------------------------------------------------
445    // TestContext Debug
446    // ---------------------------------------------------------------
447
448    #[test]
449    fn test_context_has_debug_impl() {
450        // Verify that TestContext implements Debug (compile-time check).
451        fn assert_debug<T: std::fmt::Debug>() {}
452        assert_debug::<TestContext>();
453    }
454
455    #[test]
456    fn test_context_debug_shows_schema_name() {
457        // We can't easily construct a full TestContext without a real DB,
458        // but we can test the Debug format by constructing the expected string.
459        // The Debug impl should show schema field.
460        let schema = "__bsql_test_12345_0";
461        let expected = format!("TestContext {{ schema: {:?} }}", schema);
462        // Just verify the format pattern is correct
463        assert!(expected.contains("TestContext"));
464        assert!(expected.contains("schema"));
465        assert!(expected.contains(schema));
466    }
467
468    // ---------------------------------------------------------------
469    // Drop behavior
470    // ---------------------------------------------------------------
471
472    #[test]
473    fn drop_code_path_with_invalid_url_does_not_panic() {
474        // We can't construct a TestContext without a real Pool (async), so we
475        // exercise the exact Drop code path manually. This is the same logic
476        // that TestContext::drop executes.
477        let db_url = "garbage-url";
478        let schema_name = "__bsql_test_fake_0";
479        // Step 1: Config::from_url — should fail for a garbage URL
480        if let Ok(config) = Config::from_url(db_url) {
481            // Step 2: Connection::connect — would fail but we shouldn't reach here
482            if let Ok(mut conn) = Connection::connect(&config) {
483                let _ = conn.simple_query(&format!(
484                    "DROP SCHEMA IF EXISTS \"{}\" CASCADE",
485                    schema_name
486                ));
487            }
488        }
489        // If we get here without panicking, the drop path is safe.
490    }
491
492    #[test]
493    fn drop_with_garbage_url_does_not_panic() {
494        // Directly exercise the Drop code path with an invalid URL.
495        // This ensures Config::from_url failure doesn't cause a panic in Drop.
496        //
497        // We test the conditional logic in Drop:
498        //   if let Ok(config) = Config::from_url(&self.db_url) { ... }
499        // An invalid URL means Config::from_url returns Err, so drop exits silently.
500        let db_url = "not-a-postgres-url";
501        let config_result = Config::from_url(db_url);
502        assert!(config_result.is_err(), "garbage URL should not parse");
503        // The Drop impl would exit at the first `if let Ok(...)` — no panic.
504    }
505
506    #[test]
507    fn drop_with_valid_url_but_unreachable_host_does_not_panic() {
508        // Even if Config::from_url succeeds, Connection::connect can fail.
509        // Drop should handle this gracefully.
510        let db_url = "postgres://user:pass@127.0.0.1:1/testdb";
511        let config = Config::from_url(db_url);
512        assert!(config.is_ok(), "URL should parse");
513        let conn_result = Connection::connect(&config.unwrap());
514        assert!(conn_result.is_err(), "connection to port 1 should fail");
515        // The Drop impl would exit at the second `if let Ok(...)` — no panic.
516    }
517
518    // ---------------------------------------------------------------
519    // Fixture edge cases (tested via the setup function's logic)
520    // ---------------------------------------------------------------
521
522    #[test]
523    fn empty_fixture_string_is_skipped() {
524        // The setup function skips empty fixtures: `if !fixture_sql.trim().is_empty()`
525        // Verify the logic directly.
526        let fixture = "";
527        assert!(fixture.trim().is_empty(), "empty string should be skipped");
528    }
529
530    #[test]
531    fn whitespace_only_fixture_is_skipped() {
532        let fixture = "   \n\t  \n  ";
533        assert!(
534            fixture.trim().is_empty(),
535            "whitespace-only fixture should be skipped"
536        );
537    }
538
539    #[test]
540    fn fixture_with_only_comments_is_not_empty() {
541        // SQL comments are not whitespace, so they pass the trim check.
542        // PostgreSQL will accept them as valid SQL (no-op).
543        let fixture = "-- just a comment\n/* block comment */";
544        assert!(
545            !fixture.trim().is_empty(),
546            "comment-only fixture should NOT be skipped (PG handles it)"
547        );
548    }
549
550    #[test]
551    fn fixture_with_multiple_statements_passes_trim_check() {
552        let fixture = "CREATE TABLE a (id INT);\nCREATE TABLE b (id INT);";
553        assert!(!fixture.trim().is_empty());
554    }
555
556    // ---------------------------------------------------------------
557    // Error type verification
558    // ---------------------------------------------------------------
559
560    #[test]
561    fn missing_env_error_is_connect_variant() {
562        let err =
563            ConnectError::create("BSQL_DATABASE_URL or DATABASE_URL must be set for #[bsql::test]");
564        match err {
565            BsqlError::Connect(ref ce) => {
566                assert!(ce.message.contains("BSQL_DATABASE_URL"));
567            }
568            _ => panic!("expected Connect variant"),
569        }
570    }
571
572    #[test]
573    fn invalid_url_error_is_connect_variant() {
574        let err = ConnectError::create("invalid database URL: missing postgres:// prefix");
575        match err {
576            BsqlError::Connect(ref ce) => {
577                assert!(ce.message.contains("invalid database URL"));
578            }
579            _ => panic!("expected Connect variant"),
580        }
581    }
582
583    #[test]
584    fn connection_failed_error_is_connect_variant() {
585        let err = ConnectError::create("connection failed: Connection refused");
586        match err {
587            BsqlError::Connect(ref ce) => {
588                assert!(ce.message.contains("connection failed"));
589            }
590            _ => panic!("expected Connect variant"),
591        }
592    }
593
594    #[test]
595    fn fixture_failed_error_is_connect_variant() {
596        let err = ConnectError::create("fixture failed: syntax error at position 5");
597        match err {
598            BsqlError::Connect(ref ce) => {
599                assert!(ce.message.contains("fixture failed"));
600            }
601            _ => panic!("expected Connect variant"),
602        }
603    }
604
605    #[test]
606    fn schema_creation_failed_error_is_connect_variant() {
607        let err = ConnectError::create("failed to create test schema: permission denied");
608        match err {
609            BsqlError::Connect(ref ce) => {
610                assert!(ce.message.contains("failed to create test schema"));
611            }
612            _ => panic!("expected Connect variant"),
613        }
614    }
615
616    // ---------------------------------------------------------------
617    // Schema name format deep verification
618    // ---------------------------------------------------------------
619
620    #[test]
621    fn schema_name_has_three_parts() {
622        let counter = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
623        let pid = std::process::id();
624        let name = format!("__bsql_test_{}_{}", pid, counter);
625        // Parts: prefix "__bsql_test", pid, counter
626        assert!(name.starts_with("__bsql_test_"));
627        let suffix = &name["__bsql_test_".len()..];
628        let parts: Vec<&str> = suffix.split('_').collect();
629        assert_eq!(parts.len(), 2, "expected PID_COUNTER suffix, got: {suffix}");
630        assert_eq!(parts[0], pid.to_string());
631        assert_eq!(parts[1], counter.to_string());
632    }
633
634    #[test]
635    fn schema_name_counter_part_increases() {
636        let c1 = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
637        let c2 = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
638        let pid = std::process::id();
639        let name1 = format!("__bsql_test_{}_{}", pid, c1);
640        let name2 = format!("__bsql_test_{}_{}", pid, c2);
641        // Extract counter from name
642        let counter1: u64 = name1.rsplit('_').next().unwrap().parse().unwrap();
643        let counter2: u64 = name2.rsplit('_').next().unwrap().parse().unwrap();
644        assert!(counter2 > counter1);
645    }
646
647    // ---------------------------------------------------------------
648    // BSQL_DATABASE_URL takes priority over DATABASE_URL
649    // ---------------------------------------------------------------
650
651    #[tokio::test]
652    async fn bsql_database_url_takes_priority_over_database_url() {
653        let orig_bsql = std::env::var("BSQL_DATABASE_URL").ok();
654        let orig_db = std::env::var("DATABASE_URL").ok();
655
656        // Set both — BSQL_DATABASE_URL should win
657        // Use an invalid URL so we can see which one is used in the error
658        std::env::set_var("BSQL_DATABASE_URL", "not-postgres-bsql");
659        std::env::set_var("DATABASE_URL", "postgres://user:pass@127.0.0.1:1/realdb");
660
661        let result = setup_test_schema(&[]).await;
662        assert!(result.is_err());
663        let msg = result.unwrap_err().to_string();
664        // Should fail because BSQL_DATABASE_URL is not a valid postgres URL
665        assert!(
666            msg.contains("invalid database URL"),
667            "BSQL_DATABASE_URL should take priority, got: {msg}"
668        );
669
670        // Restore
671        std::env::remove_var("BSQL_DATABASE_URL");
672        std::env::remove_var("DATABASE_URL");
673        if let Some(v) = orig_bsql {
674            std::env::set_var("BSQL_DATABASE_URL", v);
675        }
676        if let Some(v) = orig_db {
677            std::env::set_var("DATABASE_URL", v);
678        }
679    }
680
681    // ---------------------------------------------------------------
682    // PG: concurrent schema name uniqueness (threaded)
683    // ---------------------------------------------------------------
684
685    #[test]
686    fn pg_schema_names_100_unique_across_threads() {
687        use std::sync::Arc;
688        let results: Arc<std::sync::Mutex<Vec<String>>> =
689            Arc::new(std::sync::Mutex::new(Vec::new()));
690        let handles: Vec<_> = (0..100)
691            .map(|_| {
692                let results = Arc::clone(&results);
693                std::thread::spawn(move || {
694                    let name = format!(
695                        "__bsql_test_{}_{}",
696                        std::process::id(),
697                        TEST_COUNTER.fetch_add(1, Ordering::Relaxed),
698                    );
699                    results.lock().unwrap().push(name);
700                })
701            })
702            .collect();
703        for h in handles {
704            h.join().unwrap();
705        }
706        let names = results.lock().unwrap();
707        let unique: HashSet<&String> = names.iter().collect();
708        assert_eq!(
709            unique.len(),
710            100,
711            "all 100 schema names must be unique across threads"
712        );
713    }
714
715    // ---------------------------------------------------------------
716    // PG: TestContext Debug format verification
717    // ---------------------------------------------------------------
718
719    #[test]
720    fn test_context_debug_format_matches_pattern() {
721        // Verify the Debug impl output matches the expected pattern exactly.
722        // We can't construct a TestContext without a real DB, but we can
723        // verify the format by inspecting the derive output shape.
724        let schema = "__bsql_test_99999_42";
725        let expected = format!("TestContext {{ schema: {:?} }}", schema);
726        // Should match: TestContext { schema: "__bsql_test_99999_42" }
727        assert!(expected.starts_with("TestContext { schema: \""));
728        assert!(expected.ends_with("\" }"));
729        assert!(expected.contains("__bsql_test_99999_42"));
730    }
731
732    // ===================================================================
733    // SQLite test support
734    // ===================================================================
735
736    #[cfg(feature = "sqlite")]
737    mod sqlite_tests {
738        use super::super::*;
739
740        #[test]
741        fn sqlite_test_context_creates_file() {
742            let ctx = setup_sqlite_test(&["CREATE TABLE t (id INTEGER PRIMARY KEY)"]).unwrap();
743            assert!(ctx.db_path.exists());
744        }
745
746        #[test]
747        fn sqlite_test_context_drop_removes_file() {
748            let path;
749            {
750                let ctx = setup_sqlite_test(&[]).unwrap();
751                path = ctx.db_path.clone();
752                assert!(path.exists());
753            }
754            // After drop
755            assert!(!path.exists(), "temp db should be deleted on drop");
756        }
757
758        #[test]
759        fn sqlite_fixtures_applied() {
760            let ctx = setup_sqlite_test(&[
761                "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)",
762                "INSERT INTO users (name) VALUES ('Alice')",
763            ])
764            .unwrap();
765            // Verify via simple_exec + query
766            let sql = "SELECT name FROM users";
767            let hash = crate::rapid_hash_str(sql);
768            let (result, arena) = ctx
769                .pool
770                .query_readonly(sql, hash, smallvec::SmallVec::new())
771                .unwrap();
772            assert_eq!(result.len(), 1);
773            assert_eq!(result.get_str(0, 0, &arena), Some("Alice"));
774        }
775
776        #[test]
777        fn sqlite_unique_paths() {
778            let ctx1 = setup_sqlite_test(&[]).unwrap();
779            let ctx2 = setup_sqlite_test(&[]).unwrap();
780            assert_ne!(ctx1.db_path, ctx2.db_path);
781        }
782
783        #[test]
784        fn sqlite_empty_fixture_works() {
785            let ctx = setup_sqlite_test(&[""]).unwrap();
786            assert!(ctx.db_path.exists());
787        }
788
789        #[test]
790        fn sqlite_multiple_fixtures_order() {
791            let ctx = setup_sqlite_test(&[
792                "CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)",
793                "INSERT INTO t (val) VALUES ('first')",
794                "INSERT INTO t (val) VALUES ('second')",
795            ])
796            .unwrap();
797            let sql = "SELECT val FROM t ORDER BY id";
798            let hash = crate::rapid_hash_str(sql);
799            let (result, arena) = ctx
800                .pool
801                .query_readonly(sql, hash, smallvec::SmallVec::new())
802                .unwrap();
803            assert_eq!(result.len(), 2);
804            assert_eq!(result.get_str(0, 0, &arena), Some("first"));
805            assert_eq!(result.get_str(1, 0, &arena), Some("second"));
806        }
807
808        #[test]
809        fn sqlite_fixture_error_propagates() {
810            let result = setup_sqlite_test(&["NOT VALID SQL AT ALL !@#"]);
811            assert!(result.is_err(), "bad SQL should propagate as Err");
812        }
813
814        #[test]
815        fn sqlite_wal_shm_cleaned() {
816            let path;
817            {
818                let ctx = setup_sqlite_test(&[
819                    "CREATE TABLE t (id INTEGER PRIMARY KEY)",
820                    "INSERT INTO t VALUES (1)",
821                ])
822                .unwrap();
823                path = ctx.db_path.clone();
824            }
825            // WAL and SHM files should be cleaned up too
826            assert!(
827                !std::path::Path::new(&format!("{}-wal", path.display())).exists(),
828                "WAL file should be removed"
829            );
830            assert!(
831                !std::path::Path::new(&format!("{}-shm", path.display())).exists(),
832                "SHM file should be removed"
833            );
834        }
835
836        #[test]
837        fn sqlite_whitespace_only_fixture_skipped() {
838            // Whitespace-only fixture should not error
839            let ctx = setup_sqlite_test(&["  \n\t  "]).unwrap();
840            assert!(ctx.db_path.exists());
841        }
842
843        #[test]
844        fn sqlite_path_contains_pid() {
845            let ctx = setup_sqlite_test(&[]).unwrap();
846            let path_str = ctx.db_path.to_string_lossy().to_string();
847            assert!(
848                path_str.contains(&std::process::id().to_string()),
849                "path should contain PID: {path_str}"
850            );
851        }
852
853        #[test]
854        fn sqlite_path_has_db_extension() {
855            let ctx = setup_sqlite_test(&[]).unwrap();
856            let path_str = ctx.db_path.to_string_lossy().to_string();
857            assert!(
858                path_str.ends_with(".db"),
859                "path should end with .db: {path_str}"
860            );
861        }
862
863        // ---------------------------------------------------------------
864        // Drop during panic / unwind
865        // ---------------------------------------------------------------
866
867        #[test]
868        fn sqlite_cleanup_on_panic() {
869            use std::sync::Arc;
870            use std::sync::Mutex;
871
872            let captured_path = Arc::new(Mutex::new(None));
873            let path_clone = captured_path.clone();
874
875            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
876                let ctx = setup_sqlite_test(&["CREATE TABLE t (id INTEGER)"]).unwrap();
877                *path_clone.lock().unwrap() = Some(ctx.db_path.clone());
878                panic!("simulated failure");
879            }));
880
881            assert!(result.is_err());
882            let path = captured_path.lock().unwrap().clone().unwrap();
883            // Drop should have run during unwind, cleaning the file
884            assert!(
885                !path.exists(),
886                "temp file should be cleaned even after panic"
887            );
888        }
889
890        // ---------------------------------------------------------------
891        // Explicit drop does not panic
892        // ---------------------------------------------------------------
893
894        #[test]
895        fn sqlite_drop_with_open_file_handle() {
896            // Verify Drop doesn't panic even if called explicitly
897            let ctx = setup_sqlite_test(&[]).unwrap();
898            let path = ctx.db_path.clone();
899            // Explicitly drop -- should not panic
900            drop(ctx);
901            // File may or may not exist depending on OS behavior
902            // but the important thing is no panic
903            let _ = path;
904        }
905
906        // ---------------------------------------------------------------
907        // Setup failure cleans up temp file
908        // ---------------------------------------------------------------
909
910        #[test]
911        fn sqlite_setup_with_invalid_sql_propagates_error() {
912            // Force an error in fixture application
913            let result = setup_sqlite_test(&["NOT VALID SQL AT ALL !!!"]);
914            assert!(result.is_err());
915            // The setup creates the file, then tries to apply fixtures.
916            // On failure, the SqliteTestContext is never returned, but the
917            // pool + file were created. Since the Ok path is never reached,
918            // the file may linger. This tests error propagation.
919        }
920
921        // ---------------------------------------------------------------
922        // Concurrent 100 temp files — all unique, all cleaned
923        // ---------------------------------------------------------------
924
925        #[test]
926        fn sqlite_concurrent_100_temp_files() {
927            let handles: Vec<_> = (0..100)
928                .map(|_| {
929                    std::thread::spawn(|| {
930                        let ctx = setup_sqlite_test(&[
931                            "CREATE TABLE t (id INTEGER PRIMARY KEY)",
932                            "INSERT INTO t VALUES (1)",
933                        ])
934                        .unwrap();
935                        let path = ctx.db_path.clone();
936                        assert!(path.exists());
937                        // Drop cleans up
938                        drop(ctx);
939                        assert!(!path.exists());
940                        path
941                    })
942                })
943                .collect();
944
945            let paths: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
946
947            // All paths should be unique
948            let unique: std::collections::HashSet<_> = paths.iter().collect();
949            assert_eq!(unique.len(), 100, "all 100 paths should be unique");
950        }
951
952        // ---------------------------------------------------------------
953        // Error message quality
954        // ---------------------------------------------------------------
955
956        #[test]
957        fn sqlite_error_message_is_descriptive() {
958            let result = setup_sqlite_test(&["INVALID SQL"]);
959            match result {
960                Err(e) => {
961                    let msg = e.to_string();
962                    assert!(
963                        msg.contains("fixture"),
964                        "error should mention fixture: {msg}"
965                    );
966                }
967                Ok(_) => panic!("should have failed"),
968            }
969        }
970
971        // ---------------------------------------------------------------
972        // Fixtures with foreign key dependencies (order matters)
973        // ---------------------------------------------------------------
974
975        #[test]
976        fn sqlite_fixtures_order_dependent_with_fk() {
977            // Second fixture references table from first via foreign key
978            let ctx = setup_sqlite_test(&[
979                "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
980                "CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id))",
981                "INSERT INTO users VALUES (1, 'Alice')",
982                "INSERT INTO orders VALUES (1, 1)",
983            ])
984            .unwrap();
985            // If order was wrong, the CREATE TABLE orders would fail
986            assert!(ctx.db_path.exists());
987        }
988
989        // ---------------------------------------------------------------
990        // Large fixture (1000 rows)
991        // ---------------------------------------------------------------
992
993        #[test]
994        fn sqlite_large_fixture() {
995            let mut sql = String::from("CREATE TABLE big (id INTEGER PRIMARY KEY, data TEXT);\n");
996            for i in 0..1000 {
997                sql.push_str(&format!("INSERT INTO big VALUES ({i}, 'row_{i}');\n"));
998            }
999            let ctx = setup_sqlite_test(&[&sql]).unwrap();
1000            assert!(ctx.db_path.exists());
1001        }
1002
1003        // ---------------------------------------------------------------
1004        // Fixture with PRAGMA statement
1005        // ---------------------------------------------------------------
1006
1007        #[test]
1008        fn sqlite_fixture_with_pragma() {
1009            let ctx = setup_sqlite_test(&[
1010                "PRAGMA foreign_keys = ON",
1011                "CREATE TABLE t (id INTEGER PRIMARY KEY)",
1012            ])
1013            .unwrap();
1014            assert!(ctx.db_path.exists());
1015        }
1016    }
1017}