use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, MutexGuard};
use std::thread::sleep;
use std::time::{Duration, Instant};
use crate::database::{Database, DatabaseConfig, TOKEN_ENV, URL_ENV};
mod idp;
pub use idp::{TEST_CLIENT_ID, TestIdentityProvider, TokenSpec};
pub const TEST_URL_ENV: &str = "TURSO_TEST_DATABASE_URL";
pub const TEST_TOKEN_ENV: &str = "TURSO_TEST_AUTH_TOKEN";
static CLOUD_LOCK: Mutex<()> = Mutex::new(());
pub struct TestDatabase {
config: DatabaseConfig,
server: Option<Child>,
dir: Option<PathBuf>,
_cloud: Option<MutexGuard<'static, ()>>,
}
impl TestDatabase {
pub fn start() -> Self {
let _ = crate::env::load_dotenv();
match std::env::var(TEST_URL_ENV)
.ok()
.filter(|url| !url.trim().is_empty())
{
Some(url) => Self::cloud(DatabaseConfig {
url,
auth_token: std::env::var(TEST_TOKEN_ENV).unwrap_or_default(),
}),
None => Self::local(),
}
}
fn cloud(config: DatabaseConfig) -> Self {
let guard = CLOUD_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let wipe_config = config.clone();
std::thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime");
runtime.block_on(async {
let database = Database::open(&wipe_config).await?;
let conn = database.connect().await?;
crate::migration::drop_all_tables(&conn).await
})
})
.join()
.expect("emptying the test database panicked")
.unwrap_or_else(|err| panic!("cannot empty {TEST_URL_ENV} ({}): {err}", config.url));
Self {
config,
server: None,
dir: None,
_cloud: Some(guard),
}
}
fn local() -> Self {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("slave-testdb-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create test database directory");
let port = TcpListener::bind("127.0.0.1:0")
.and_then(|listener| listener.local_addr())
.expect("find a free port")
.port();
let addr = format!("127.0.0.1:{port}");
let mut sqld = Command::new("sqld");
sqld.args(["--no-welcome", "--http-listen-addr", &addr, "-d"])
.arg(dir.join("data"));
let mut turso = Command::new("turso");
turso
.args(["dev", "--port", &port.to_string(), "--db-file"])
.arg(dir.join("test.db"));
let server = [sqld, turso]
.into_iter()
.find_map(|mut command| {
command
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()
})
.unwrap_or_else(|| {
panic!(
"tests need a database: set {TEST_URL_ENV} and {TEST_TOKEN_ENV} to a dedicated \
Turso Cloud test database (it is emptied by every test), or install the \
Turso CLI (https://docs.turso.tech/cli) for a local server"
)
});
let deadline = Instant::now() + Duration::from_secs(20);
while TcpStream::connect(&addr).is_err() {
assert!(
Instant::now() < deadline,
"local test database did not start"
);
sleep(Duration::from_millis(50));
}
Self {
config: DatabaseConfig {
url: format!("http://{addr}"),
auth_token: String::new(),
},
server: Some(server),
dir: Some(dir),
_cloud: None,
}
}
pub fn config(&self) -> &DatabaseConfig {
&self.config
}
pub fn envs(&self) -> [(&'static str, &str); 2] {
[
(URL_ENV, self.config.url.as_str()),
(TOKEN_ENV, self.config.auth_token.as_str()),
]
}
pub async fn database(&self) -> Database {
Database::open(&self.config)
.await
.expect("open the test database")
}
}
impl Drop for TestDatabase {
fn drop(&mut self) {
if let Some(mut server) = self.server.take() {
#[cfg(unix)]
{
let _ = Command::new("kill")
.args(["-TERM", &server.id().to_string()])
.status();
let deadline = Instant::now() + Duration::from_secs(3);
while matches!(server.try_wait(), Ok(None)) && Instant::now() < deadline {
sleep(Duration::from_millis(20));
}
}
let _ = server.kill();
let _ = server.wait();
}
if let Some(dir) = self.dir.take() {
let _ = std::fs::remove_dir_all(dir);
}
}
}