use super::admin::MAX_LOGS_TAIL_N;
use super::status::{first_existing_mtime_rfc3339, index_disk_and_mtime};
use super::*;
use crate::core::embed::Embedder;
use crate::core::registry::IndexRegistry;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::Json;
#[test]
fn index_disk_and_mtime_handles_missing_dir() {
let id = format!("nonexistent-index-{}", std::process::id());
let (disk, mtime) = index_disk_and_mtime(&id);
assert!(disk.is_none(), "missing dir yields no disk_bytes");
assert!(mtime.is_none(), "missing dir yields no last_indexed");
}
#[test]
fn last_indexed_prefers_redb_then_chunks_json() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path();
std::fs::write(dir.join("chunks.json"), b"[]").expect("write chunks.json");
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(dir.join("index.redb"), b"redb").expect("write index.redb");
let redb_mtime = std::fs::metadata(dir.join("index.redb"))
.and_then(|m| m.modified())
.map(|t| chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339())
.expect("redb mtime");
let got = first_existing_mtime_rfc3339(dir, &["index.redb", "chunks.json"]);
assert_eq!(
got.as_deref(),
Some(redb_mtime.as_str()),
"selector must prefer index.redb mtime over chunks.json"
);
let tmp2 = tempfile::tempdir().expect("tempdir2");
std::fs::write(tmp2.path().join("chunks.json"), b"[]").expect("write chunks.json");
let fallback = first_existing_mtime_rfc3339(tmp2.path(), &["index.redb", "chunks.json"]);
assert!(
fallback.is_some(),
"selector must fall back to chunks.json when index.redb is absent"
);
}
#[test]
fn last_indexed_none_when_no_candidates_exist() {
let tmp = tempfile::tempdir().expect("tempdir");
let got = first_existing_mtime_rfc3339(tmp.path(), &["index.redb", "chunks.json"]);
assert!(got.is_none(), "no candidate files → None");
}
#[tokio::test]
async fn health_omits_embedder_info_when_bm25_only() {
let state = Arc::new(SearchAppState::new(IndexRegistry::new()));
let Json(resp) = health_handler(State(state)).await;
assert!(
resp.embedder_info.is_none(),
"BM25-only daemon must omit embedder_info"
);
}
#[tokio::test]
async fn logs_tail_returns_recent_lines() {
let buffer = trusty_common::log_buffer::LogBuffer::new(100);
buffer.push("line one".to_string());
buffer.push("line two".to_string());
buffer.push("line three".to_string());
let state = Arc::new(SearchAppState::new(IndexRegistry::new()).with_log_buffer(buffer));
let Json(body) = logs_tail_handler(State(state), Query(LogsTailParams { n: 2 })).await;
let lines = body["lines"].as_array().expect("lines array");
assert_eq!(lines.len(), 2, "n=2 must return two lines");
assert_eq!(lines[0].as_str(), Some("line two"));
assert_eq!(lines[1].as_str(), Some("line three"));
assert_eq!(body["total"].as_u64(), Some(3), "total counts all buffered");
}
#[tokio::test]
async fn logs_tail_clamps_n() {
let buffer = trusty_common::log_buffer::LogBuffer::new(100);
for i in 0..5 {
buffer.push(format!("l{i}"));
}
let state = Arc::new(SearchAppState::new(IndexRegistry::new()).with_log_buffer(buffer));
let Json(zero) =
logs_tail_handler(State(Arc::clone(&state)), Query(LogsTailParams { n: 0 })).await;
assert_eq!(zero["lines"].as_array().expect("lines").len(), 1);
let Json(big) = logs_tail_handler(
State(state),
Query(LogsTailParams {
n: MAX_LOGS_TAIL_N * 10,
}),
)
.await;
assert_eq!(big["lines"].as_array().expect("lines").len(), 5);
}
#[tokio::test]
async fn admin_stop_returns_ok() {
let state = Arc::new(SearchAppState::new(IndexRegistry::new()));
let Json(body) = admin_stop_handler(State(state)).await;
assert_eq!(body["ok"], serde_json::Value::Bool(true));
assert_eq!(body["message"].as_str(), Some("shutting down"));
}
#[tokio::test]
async fn create_index_rejects_relative_root_path() {
use crate::core::registry::IndexRegistry;
use axum::body::to_bytes;
let state = SearchAppState::new(IndexRegistry::new());
let embedder: Arc<dyn Embedder> = Arc::new(crate::core::embed::MockEmbedder::new(8));
state.install_embedder(embedder).await;
let state_arc = Arc::new(state);
let resp = create_index_handler(
State(state_arc),
Json(CreateIndexRequest {
id: "rel-bad".into(),
root_path: std::path::PathBuf::from("claude-mpm"),
include_paths: None,
exclude_globs: None,
extensions: None,
domain_terms: None,
path_filter: None,
include_docs: None,
respect_gitignore: None,
lexical_only: None,
skip_kg: None,
defer_embed: None,
}),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(resp.into_body(), 4096).await.expect("body");
let v: serde_json::Value = serde_json::from_slice(&body).expect("json");
let err = v.get("error").and_then(|x| x.as_str()).unwrap_or("");
assert!(err.contains("absolute"), "got: {err}");
}
#[tokio::test]
async fn create_index_rejects_nonexistent_root_path() {
use crate::core::registry::IndexRegistry;
use axum::body::to_bytes;
let state = SearchAppState::new(IndexRegistry::new());
let embedder: Arc<dyn Embedder> = Arc::new(crate::core::embed::MockEmbedder::new(8));
state.install_embedder(embedder).await;
let state_arc = Arc::new(state);
let resp = create_index_handler(
State(state_arc),
Json(CreateIndexRequest {
id: "ghost".into(),
root_path: std::path::PathBuf::from(
"/this/path/should/never/exist/trusty-search-test-xyz",
),
include_paths: None,
exclude_globs: None,
extensions: None,
domain_terms: None,
path_filter: None,
include_docs: None,
respect_gitignore: None,
lexical_only: None,
skip_kg: None,
defer_embed: None,
}),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(resp.into_body(), 4096).await.expect("body");
let v: serde_json::Value = serde_json::from_slice(&body).expect("json");
let err = v.get("error").and_then(|x| x.as_str()).unwrap_or("");
assert!(err.contains("does not exist"), "got: {err}");
}
#[cfg(unix)]
#[tokio::test]
async fn create_index_canonicalizes_symlinked_root_path() {
use crate::core::registry::IndexId;
use crate::core::registry::IndexRegistry;
use std::os::unix::fs::symlink;
let state = SearchAppState::new(IndexRegistry::new());
let embedder: Arc<dyn Embedder> = Arc::new(crate::core::embed::MockEmbedder::new(8));
state.install_embedder(embedder).await;
let state_arc = Arc::new(state);
let cwd = std::env::current_dir().expect("cwd");
let base = cwd.join("target");
std::fs::create_dir_all(&base).expect("create target/");
let real_dir = tempfile::Builder::new()
.prefix("ts-symlink-real-")
.tempdir_in(&base)
.expect("create real_root under target/");
let real_root = std::fs::canonicalize(real_dir.path()).expect("canonicalize real root");
let link_path = base.join(format!("ts-symlink-link-{}", std::process::id()));
let _ = std::fs::remove_file(&link_path);
symlink(&real_root, &link_path).expect("create symlink");
let resp = create_index_handler(
State(Arc::clone(&state_arc)),
Json(CreateIndexRequest {
id: "symlinked".into(),
root_path: link_path.clone(),
include_paths: None,
exclude_globs: None,
extensions: None,
domain_terms: None,
path_filter: None,
include_docs: None,
respect_gitignore: None,
lexical_only: None,
skip_kg: None,
defer_embed: None,
}),
)
.await;
let _ = std::fs::remove_file(&link_path); assert_eq!(resp.status(), StatusCode::OK);
let handle = state_arc
.registry
.get(&IndexId::new("symlinked"))
.expect("registered handle");
assert_eq!(
handle.root_path, real_root,
"registry stored the symlink alias instead of the canonical path",
);
assert_ne!(
handle.root_path, link_path,
"registry retained the symlink alias — downstream walkers will mismatch",
);
}
#[tokio::test]
async fn create_index_accepts_valid_absolute_root_path() {
use crate::core::registry::IndexRegistry;
let state = SearchAppState::new(IndexRegistry::new());
let embedder: Arc<dyn Embedder> = Arc::new(crate::core::embed::MockEmbedder::new(8));
state.install_embedder(embedder).await;
let state_arc = Arc::new(state);
let cwd = std::env::current_dir().expect("cwd");
let base = cwd.join("target");
std::fs::create_dir_all(&base).expect("create target/");
let test_dir = tempfile::Builder::new()
.prefix("ts-valid-abs-")
.tempdir_in(&base)
.expect("create test_dir under target/");
let resp = create_index_handler(
State(Arc::clone(&state_arc)),
Json(CreateIndexRequest {
id: "valid-abs".into(),
root_path: test_dir.path().to_path_buf(),
include_paths: None,
exclude_globs: None,
extensions: None,
domain_terms: None,
path_filter: None,
include_docs: None,
respect_gitignore: None,
lexical_only: None,
skip_kg: None,
defer_embed: None,
}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
}