slave-framework 0.6.0

Runtime for slave apps (a Laravel-inspired Rust framework for MCP-first apps): server lifecycle, config, logging, Turso Cloud database and migrations, and OpenID Connect sign-in
Documentation
//! Test doubles for apps' integration tests: a database ([`TestDatabase`])
//! and an OpenID Connect provider ([`TestIdentityProvider`]).
//!
//! # A database
//!
//! - With `TURSO_TEST_DATABASE_URL` (and `TURSO_TEST_AUTH_TOKEN`) set, in the
//!   environment or `.env`, tests use that **dedicated** Turso Cloud
//!   database. It is emptied before every test, and tests that use it run one
//!   at a time. Never point it at a database whose data you need.
//! - Otherwise each test gets its own throwaway local server (`sqld`, or
//!   `turso dev` from the [Turso CLI](https://docs.turso.tech/cli)), which
//!   speaks the same protocol as Turso Cloud.

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};

/// Environment variable naming a dedicated Turso Cloud test database.
pub const TEST_URL_ENV: &str = "TURSO_TEST_DATABASE_URL";
/// Its auth token.
pub const TEST_TOKEN_ENV: &str = "TURSO_TEST_AUTH_TOKEN";

/// Serializes tests sharing the cloud test database within a test binary.
static CLOUD_LOCK: Mutex<()> = Mutex::new(());

/// An empty database for one test, released when dropped.
pub struct TestDatabase {
    config: DatabaseConfig,
    server: Option<Child>,
    dir: Option<PathBuf>,
    _cloud: Option<MutexGuard<'static, ()>>,
}

impl TestDatabase {
    /// Provides an empty database. Blocking; works from sync and async tests.
    ///
    /// Panics with setup instructions if neither a test database URL nor a
    /// local server (`sqld` / `turso`) is available.
    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}");

        // `sqld` directly when available, so stopping it never leaves a server behind.
        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
    }

    /// `TURSO_DATABASE_URL` and `TURSO_AUTH_TOKEN` for processes under test.
    pub fn envs(&self) -> [(&'static str, &str); 2] {
        [
            (URL_ENV, self.config.url.as_str()),
            (TOKEN_ENV, self.config.auth_token.as_str()),
        ]
    }

    /// A client for this database.
    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() {
            // SIGTERM first: `turso dev` stops its `sqld` child on it.
            #[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);
        }
    }
}