#![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::{Arc, OnceLock};
static TEST_ROOT: OnceLock<PathBuf> = OnceLock::new();
#[cfg(unix)]
static TEST_ROOT_CREATOR_PID: OnceLock<libc::pid_t> = 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 fn retry_tests_lock() -> std::sync::MutexGuard<'static, ()> {
static RETRY_LOCK: OnceLock<std::sync::Mutex<()>> = OnceLock::new();
RETRY_LOCK
.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_poison()
}
#[must_use]
pub(crate) struct RetryPolicyGuard {
previous: Option<crate::retry::RetryPolicy>,
}
impl Drop for RetryPolicyGuard {
fn drop(&mut self) {
crate::retry::restore_test_retry_policy(self.previous.take());
}
}
pub(crate) fn install_test_retry_policy(policy: crate::retry::RetryPolicy) -> RetryPolicyGuard {
let previous = crate::retry::swap_test_retry_policy(policy);
RetryPolicyGuard { previous }
}
pub(crate) struct FakeProvider {
script: std::sync::Mutex<
std::collections::VecDeque<Result<crate::ChatResponse, crate::providers::ScopedCallError>>,
>,
pub request_fingerprints: std::sync::Mutex<Vec<String>>,
pub request_messages: std::sync::Mutex<Vec<String>>,
}
impl FakeProvider {
#[must_use]
pub(crate) fn new() -> Self {
Self {
script: std::sync::Mutex::new(std::collections::VecDeque::new()),
request_fingerprints: std::sync::Mutex::new(Vec::new()),
request_messages: std::sync::Mutex::new(Vec::new()),
}
}
#[must_use]
pub(crate) fn ok(self, text: &str) -> Self {
self.ok_with_finish(text, None)
}
#[must_use]
pub(crate) fn ok_with_finish(self, text: &str, finish_reason: Option<&str>) -> Self {
self.script
.lock()
.unwrap()
.push_back(Ok(crate::ChatResponse {
text: Some(text.to_string()),
finish_reason: finish_reason.map(str::to_string),
..crate::ChatResponse::default()
}));
self
}
#[must_use]
pub(crate) fn ok_reasoning_only(self, reasoning: &str, finish_reason: Option<&str>) -> Self {
self.script
.lock()
.unwrap()
.push_back(Ok(crate::ChatResponse {
text: None,
reasoning: Some(crate::Reasoning {
reasoning: Some(reasoning.to_string()),
reasoning_content: Some(reasoning.to_string()),
reasoning_details: None,
}),
finish_reason: finish_reason.map(str::to_string),
..crate::ChatResponse::default()
}));
self
}
#[must_use]
pub(crate) fn ok_reasoning_only_with_usage(
self,
reasoning: &str,
finish_reason: Option<&str>,
input_tokens: u64,
output_tokens: u64,
) -> Self {
self.script
.lock()
.unwrap()
.push_back(Ok(crate::ChatResponse {
text: None,
reasoning: Some(crate::Reasoning {
reasoning: Some(reasoning.to_string()),
reasoning_content: Some(reasoning.to_string()),
reasoning_details: None,
}),
finish_reason: finish_reason.map(str::to_string),
usage: Some(crate::ProviderUsage {
input_tokens: Some(input_tokens),
output_tokens: Some(output_tokens),
..crate::ProviderUsage::default()
}),
..crate::ChatResponse::default()
}));
self
}
#[must_use]
pub(crate) fn ok_with_usage(self, text: &str, input_tokens: u64, output_tokens: u64) -> Self {
self.script
.lock()
.unwrap()
.push_back(Ok(crate::ChatResponse {
text: Some(text.to_string()),
usage: Some(crate::ProviderUsage {
input_tokens: Some(input_tokens),
output_tokens: Some(output_tokens),
..crate::ProviderUsage::default()
}),
..crate::ChatResponse::default()
}));
self
}
#[must_use]
pub(crate) fn ok_tool_call(self, name: &str) -> Self {
self.script
.lock()
.unwrap()
.push_back(Ok(crate::ChatResponse {
text: None,
tool_calls: vec![crate::ToolCall {
id: "call_test".to_string(),
name: name.to_string(),
arguments: serde_json::json!({}),
}],
finish_reason: Some("tool_calls".to_string()),
..crate::ChatResponse::default()
}));
self
}
#[must_use]
pub(crate) fn err(self, class: crate::retry::FailureClass, msg: &str) -> Self {
let inner = anyhow::anyhow!("{msg}");
let record = crate::retry::RetryFailureRecord::new_simple(class, &inner, None);
self.script
.lock()
.unwrap()
.push_back(Err(crate::providers::ScopedCallError::new(
inner, record, class,
)));
self
}
#[must_use]
pub(crate) fn err_http(self, status: u16, body: &str) -> Self {
let msg = format!("OpenRouter API error ({status}): {body}");
self.err(crate::retry::FailureClass::NonRetryable, &msg)
}
}
#[async_trait::async_trait]
impl crate::Provider for FakeProvider {
async fn chat(&self, _request: crate::ChatRequest) -> anyhow::Result<crate::ChatResponse> {
Ok(crate::ChatResponse::default())
}
async fn chat_scoped(
&self,
request: crate::ChatRequest,
_idle_timeout: std::time::Duration,
_deadline: std::time::Instant,
) -> Result<crate::ChatResponse, crate::providers::ScopedCallError> {
self.request_fingerprints
.lock()
.unwrap()
.push(format!("{request:?}"));
self.request_messages.lock().unwrap().push(
request
.messages
.iter()
.map(|m| format!("{m:?}"))
.collect::<Vec<_>>()
.join("\u{0}"),
);
self.script.lock().unwrap().pop_front().unwrap_or_else(|| {
Ok(crate::ChatResponse {
text: Some("unscripted default".to_string()),
..crate::ChatResponse::default()
})
})
}
async fn warmup(&self) -> anyhow::Result<()> {
Ok(())
}
}
#[must_use]
pub(crate) struct FakeProviderGuard {
previous: Option<Arc<dyn crate::Provider>>,
}
impl Drop for FakeProviderGuard {
fn drop(&mut self) {
crate::providers::restore_provider_for_test(self.previous.take());
}
}
pub(crate) fn install_fake_provider(provider: Arc<dyn crate::Provider>) -> FakeProviderGuard {
let previous = crate::providers::swap_provider_for_test(provider);
FakeProviderGuard { previous }
}
pub(crate) fn install_test_log_store(store: crate::logs::LogStore) -> TestLogStoreGuard {
let previous = crate::stats::swap_test_log_store(Some(store));
TestLogStoreGuard { previous }
}
#[must_use]
pub(crate) struct TestLogStoreGuard {
previous: Option<crate::logs::LogStore>,
}
impl Drop for TestLogStoreGuard {
fn drop(&mut self) {
crate::stats::swap_test_log_store(self.previous.take());
}
}
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,
}
}
pub(crate) 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);
#[cfg(unix)]
register_test_root_cleanup();
path
})
}
#[cfg(unix)]
fn register_test_root_cleanup() {
TEST_ROOT_CREATOR_PID.get_or_init(|| unsafe { libc::getpid() });
unsafe {
libc::atexit(cleanup_test_root);
}
}
#[cfg(unix)]
extern "C" fn cleanup_test_root() {
let current_pid = unsafe { libc::getpid() };
if TEST_ROOT_CREATOR_PID.get().copied() != Some(current_pid) {
return;
}
if let Some(path) = TEST_ROOT.get() {
let _ = std::fs::remove_dir_all(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>>,
priority: i64,
}
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,
priority: 1,
}
}
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) fn priority(mut self, priority: i64) -> Self {
self.priority = priority;
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,
priority: self.priority,
},
)
}
}
pub(crate) struct JobRowBuilder<'a> {
conn: &'a crate::turso::Connection,
id: String,
kind: String,
role: String,
workspace_name: String,
status: String,
task: String,
user_name: Option<String>,
channel: Option<String>,
retry_count: Option<i64>,
timestamps: Option<String>,
}
impl<'a> JobRowBuilder<'a> {
pub(crate) fn new(
conn: &'a crate::turso::Connection,
id: impl Into<String>,
kind: impl Into<String>,
role: impl Into<String>,
workspace_name: impl Into<String>,
) -> Self {
Self {
conn,
id: id.into(),
kind: kind.into(),
role: role.into(),
workspace_name: workspace_name.into(),
status: "launched".into(),
task: String::new(),
user_name: None,
channel: None,
retry_count: None,
timestamps: None,
}
}
pub(crate) fn status(mut self, status: impl Into<String>) -> Self {
self.status = status.into();
self
}
pub(crate) fn task(mut self, task: impl Into<String>) -> Self {
self.task = task.into();
self
}
pub(crate) fn user_name(mut self, user_name: impl Into<String>) -> Self {
self.user_name = Some(user_name.into());
self
}
pub(crate) fn channel(mut self, channel: impl Into<String>) -> Self {
self.channel = Some(channel.into());
self
}
pub(crate) fn retry_count(mut self, retry_count: i64) -> Self {
self.retry_count = Some(retry_count);
self
}
pub(crate) fn timestamps(mut self, timestamps: impl Into<String>) -> Self {
self.timestamps = Some(timestamps.into());
self
}
pub(crate) async fn insert(self) -> anyhow::Result<()> {
let Self {
conn,
id,
kind,
role,
workspace_name,
status,
task,
user_name,
channel,
retry_count,
timestamps,
} = self;
let mut columns = vec!["id", "kind", "status", "task", "workspace_name", "role"];
let mut values: Vec<crate::turso::Value> = vec![
crate::turso::Value::Text(id),
crate::turso::Value::Text(kind),
crate::turso::Value::Text(status),
crate::turso::Value::Text(task),
crate::turso::Value::Text(workspace_name),
crate::turso::Value::Text(role),
];
if let Some(user_name) = user_name {
columns.push("user_name");
values.push(crate::turso::Value::Text(user_name));
}
if let Some(channel) = channel {
columns.push("channel");
values.push(crate::turso::Value::Text(channel));
}
if let Some(retry_count) = retry_count {
columns.push("retry_count");
values.push(crate::turso::Value::Integer(retry_count));
}
let timestamps = timestamps.expect(
"JobRowBuilder::insert: `.timestamps()` is required — the helper never generates timestamps internally",
);
columns.push("created_at");
columns.push("updated_at");
values.push(crate::turso::Value::Text(timestamps.clone()));
values.push(crate::turso::Value::Text(timestamps));
let placeholders = vec!["?"; values.len()].join(", ");
let table = "jobs";
let sql = format!(
"INSERT INTO {table} ({}) VALUES ({placeholders})",
columns.join(", ")
);
conn.execute(&sql, values).await?;
Ok(())
}
}
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::message_router::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");
}
}
}
#[cfg(test)]
mod retry_policy_guard_tests {
use super::*;
const TINY_MAX_ATTEMPTS: u32 = 3;
#[test]
fn installs_and_restores_on_drop() {
let _lock = retry_tests_lock();
assert_eq!(
crate::retry::RetryPolicy::current().max_attempts,
crate::retry::DEFAULT_RETRY_MAX_ATTEMPTS
);
let guard = install_test_retry_policy(crate::retry::tiny_test_policy());
assert_eq!(
crate::retry::RetryPolicy::current().max_attempts,
TINY_MAX_ATTEMPTS
);
drop(guard);
assert_eq!(
crate::retry::RetryPolicy::current().max_attempts,
crate::retry::DEFAULT_RETRY_MAX_ATTEMPTS,
"guard must restore the pre-test override on drop"
);
}
#[test]
fn restores_on_panic() {
let _lock = retry_tests_lock();
let result = std::panic::catch_unwind(|| {
let _guard = install_test_retry_policy(crate::retry::tiny_test_policy());
assert_eq!(
crate::retry::RetryPolicy::current().max_attempts,
TINY_MAX_ATTEMPTS
);
panic!("intentional panic while holding the policy guard");
});
assert!(result.is_err());
assert_eq!(
crate::retry::RetryPolicy::current().max_attempts,
crate::retry::DEFAULT_RETRY_MAX_ATTEMPTS,
"a panicking test must not leak the tiny policy into later tests"
);
}
#[test]
fn lock_recovers_after_poison() {
let result = std::panic::catch_unwind(|| {
let _lock = retry_tests_lock();
panic!("intentional panic while holding the retry lock");
});
assert!(result.is_err());
let _lock = retry_tests_lock(); }
}
pub fn noisy_png(width: u32, height: u32) -> Vec<u8> {
use image::{ImageBuffer, Rgb};
let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_fn(width, height, |x, y| {
let mut v = x.wrapping_mul(0x9E37_79B9) ^ y.wrapping_mul(0x85EB_CA6B);
v ^= v >> 13;
v ^= v << 17;
v ^= v >> 5;
Rgb([
(v & 0xFF) as u8,
((v >> 8) & 0xFF) as u8,
((v >> 16) & 0xFF) as u8,
])
});
let mut out = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}