#![cfg(test)]
use crate::board::{BoardStore, Ticket, TicketParams, TicketPhase};
use crate::turso;
use crate::util::UnwrapPoison;
use crate::workspace::test_ws_named;
use std::path::PathBuf;
use std::sync::OnceLock;
static TEST_ROOT: OnceLock<PathBuf> = OnceLock::new();
pub fn env_lock() -> &'static std::sync::Mutex<()> {
static ENV_LOCK: OnceLock<std::sync::Mutex<()>> = OnceLock::new();
ENV_LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
pub struct EnvVarGuard {
_lock: std::sync::MutexGuard<'static, ()>,
key: String,
original: Option<std::ffi::OsString>,
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe {
match &self.original {
Some(val) => std::env::set_var(&self.key, val),
None => std::env::remove_var(&self.key),
}
}
}
}
#[must_use]
pub fn set_env_var(key: &str, value: Option<&str>) -> EnvVarGuard {
let guard = env_lock().lock().unwrap_poison();
let original = std::env::var_os(key);
unsafe {
match value {
Some(val) => std::env::set_var(key, val),
None => std::env::remove_var(key),
}
}
EnvVarGuard {
_lock: guard,
key: key.to_owned(),
original,
}
}
fn test_root() -> &'static PathBuf {
TEST_ROOT.get_or_init(|| {
let tmp = tempfile::TempDir::new().expect("failed to create test temp dir");
let path = tmp.path().to_path_buf();
std::mem::forget(tmp);
path
})
}
#[macro_export]
macro_rules! open_test_store {
($store:ty, $store_name:expr) => {{
let tmp = ::tempfile::TempDir::new().expect("temp dir for test store");
let store = <$store>::open(tmp.path()).await.unwrap_or_else(|e| {
::std::panic!(
"failed to open test {store_name} store: {e:?}",
store_name = $store_name
)
});
(store, tmp)
}};
}
pub(crate) async fn make_ticket(
store: &BoardStore,
ws: &crate::Workspace,
title: &str,
phase: TicketPhase,
) -> String {
TicketBuilder::new(store, ws)
.title(title)
.phase(phase)
.create()
.await
.unwrap_or_else(|e| panic!("make_ticket({title}, {phase}) failed: {e}"))
}
pub async fn expect_ticket(store: &BoardStore, id: &str) -> Ticket {
store
.get_ticket(id)
.await
.expect("BoardStore::get_ticket query failed")
.expect("expected ticket to exist")
}
pub async fn expect_ticket_phase(store: &BoardStore, id: &str) -> TicketPhase {
store
.get_ticket_phase(id)
.await
.expect("BoardStore::get_ticket_phase query failed")
.expect("expected ticket phase to exist")
}
pub fn assert_superseded_ticket(ticket: &Ticket) {
assert_eq!(ticket.phase, TicketPhase::Cancelled);
assert!(
ticket.assigned_to.is_none(),
"superseded ticket should have no assignee"
);
assert!(
ticket.is_archived,
"superseded ticket should be archived immediately"
);
}
pub(crate) struct TicketBuilder<'a> {
store: &'a BoardStore,
ws: crate::Workspace,
title: String,
desc: String,
phase: TicketPhase,
prereqs: Vec<String>,
reporter: String,
embedding: Option<Vec<u8>>,
}
impl<'a> TicketBuilder<'a> {
pub(crate) fn new(store: &'a BoardStore, ws: &crate::Workspace) -> Self {
Self {
store,
ws: ws.clone(),
title: String::new(),
desc: "desc".into(),
phase: TicketPhase::Backlog,
prereqs: Vec::new(),
reporter: "test".into(),
embedding: None,
}
}
pub(crate) fn title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
pub(crate) fn desc(mut self, desc: impl Into<String>) -> Self {
self.desc = desc.into();
self
}
pub(crate) fn phase(mut self, phase: TicketPhase) -> Self {
self.phase = phase;
self
}
pub(crate) fn prereqs(mut self, prereqs: &[String]) -> Self {
self.prereqs = prereqs.to_vec();
self
}
pub(crate) fn reporter(mut self, reporter: impl Into<String>) -> Self {
self.reporter = reporter.into();
self
}
pub(crate) fn embedding(mut self, blob: &[u8]) -> Self {
self.embedding = Some(blob.to_vec());
self
}
pub(crate) async fn create(self) -> anyhow::Result<String> {
let (store, params) = self.into_parts();
store.create_ticket(¶ms).await
}
pub(crate) async fn supersede(self, supersede_id: &str) -> anyhow::Result<String> {
let (store, params) = self.into_parts();
store.supersede_and_create(supersede_id, ¶ms).await
}
fn into_parts(self) -> (&'a BoardStore, TicketParams) {
(
self.store,
TicketParams {
title: self.title,
description: self.desc,
workspace_name: self.ws.name,
phase: self.phase,
prerequisites: self.prereqs,
reporter: self.reporter,
embedding: self.embedding,
},
)
}
}
pub async fn init_test_stores() {
static INIT: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
INIT.get_or_init(|| async {
let _ = crate::config::CONFIG.try_set_storage_root(test_root().clone());
crate::search_engine::init_global();
crate::ticket_buffer::init_global();
crate::turso::init_all_stores()
.await
.expect("failed to initialize test stores (see chained error for per-store details)");
})
.await;
}
pub async fn init_management_test_stores() {
init_test_stores().await;
let _ = crate::manager_queue::init_global();
}
pub async fn create_test_workspace(path: &str, name: &str) -> crate::Workspace {
let now = crate::turso::now();
crate::workspace::store()
.conn
.execute(
"INSERT INTO workspaces (name, path, created_at, updated_at, paused) \
VALUES (?1, ?2, ?3, ?4, ?5)",
turso::params![name, path, now.clone(), now, 0],
)
.await
.expect("insert test workspace");
test_ws_named(path, name)
}
pub(crate) fn init_temp_repo() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("create temp dir");
let repo_path = dir.path().to_path_buf();
let status = std::process::Command::new("git")
.args(["init"])
.current_dir(&repo_path)
.status()
.expect("git init");
assert!(status.success());
for (key, value) in [("user.name", "Test"), ("user.email", "test@test.com")] {
let status = std::process::Command::new("git")
.args(["config", key, value])
.current_dir(&repo_path)
.status()
.expect("git config");
assert!(status.success());
}
std::fs::write(repo_path.join("test.txt"), b"line1\nline2\nline3\n").expect("write test file");
let status = std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(&repo_path)
.status()
.expect("git add");
assert!(status.success());
let status = std::process::Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(&repo_path)
.status()
.expect("git commit");
assert!(status.success());
(dir, repo_path)
}
#[cfg(test)]
mod env_var_guard_tests {
use super::*;
#[test]
fn sets_and_restores_to_absent() {
let guard = set_env_var("MAHBOT_TEST_SET_RESTORE", Some("hello"));
assert_eq!(std::env::var("MAHBOT_TEST_SET_RESTORE"), Ok("hello".into()));
drop(guard);
assert!(
std::env::var_os("MAHBOT_TEST_SET_RESTORE").is_none(),
"guard should restore env var to absent on drop"
);
}
#[test]
fn removes_env_var() {
unsafe {
std::env::set_var("MAHBOT_TEST_REMOVE", "present");
}
let guard = set_env_var("MAHBOT_TEST_REMOVE", None);
assert!(
std::env::var_os("MAHBOT_TEST_REMOVE").is_none(),
"set_env_var(key, None) should remove the variable"
);
drop(guard);
assert_eq!(
std::env::var("MAHBOT_TEST_REMOVE"),
Ok("present".into()),
"guard should restore the original value on drop"
);
unsafe {
std::env::remove_var("MAHBOT_TEST_REMOVE");
}
}
#[test]
fn captures_and_restores_original_value() {
unsafe {
std::env::set_var("MAHBOT_TEST_CAPTURE", "original");
}
let guard = set_env_var("MAHBOT_TEST_CAPTURE", Some("override"));
assert_eq!(std::env::var("MAHBOT_TEST_CAPTURE"), Ok("override".into()));
drop(guard);
assert_eq!(
std::env::var("MAHBOT_TEST_CAPTURE"),
Ok("original".into()),
"guard should restore the original value on drop"
);
unsafe {
std::env::remove_var("MAHBOT_TEST_CAPTURE");
}
}
#[test]
fn restores_on_panic() {
unsafe {
std::env::remove_var("MAHBOT_TEST_PANIC_ABSENT");
}
let result = std::panic::catch_unwind(|| {
let _guard = set_env_var("MAHBOT_TEST_PANIC_ABSENT", Some("panic-value"));
panic!("intentional panic");
});
assert!(result.is_err());
assert!(
std::env::var_os("MAHBOT_TEST_PANIC_ABSENT").is_none(),
"MAHBOT_TEST_PANIC_ABSENT should be absent after panic-restore"
);
}
#[test]
fn restores_original_on_panic() {
unsafe {
std::env::set_var("MAHBOT_TEST_PANIC_ORIGINAL", "original");
}
let result = std::panic::catch_unwind(|| {
let _guard = set_env_var("MAHBOT_TEST_PANIC_ORIGINAL", Some("panic-value"));
panic!("intentional panic");
});
assert!(result.is_err());
assert_eq!(
std::env::var("MAHBOT_TEST_PANIC_ORIGINAL"),
Ok("original".into()),
"should restore original value after panic"
);
unsafe {
std::env::remove_var("MAHBOT_TEST_PANIC_ORIGINAL");
}
}
}