use super::*;
use crate::core::embed::Embedder;
use crate::core::registry::IndexRegistry;
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
#[tokio::test]
async fn install_embedder_error_surfaces_in_health() {
use crate::core::registry::IndexRegistry;
let state = SearchAppState::new(IndexRegistry::new());
state
.install_embedder_error("init timed out after 60s")
.await;
let state_arc = Arc::new(state);
let Json(resp) = health_handler(State(state_arc)).await;
assert_eq!(resp.embedder, "error");
assert_eq!(
resp.embedder_error.as_deref(),
Some("init timed out after 60s"),
);
}
#[tokio::test]
async fn create_index_returns_503_with_error_when_embedder_failed() {
use crate::core::registry::IndexRegistry;
use axum::body::to_bytes;
let state = SearchAppState::new(IndexRegistry::new());
state
.install_embedder_error("init timed out after 60s")
.await;
let state_arc = Arc::new(state);
let base = std::env::current_dir().expect("cwd").join("target");
std::fs::create_dir_all(&base).ok();
let test_dir = tempfile::Builder::new()
.prefix("ts-embedder-fail-")
.tempdir_in(&base)
.expect("create test_dir under target/");
let resp = create_index_handler(
State(state_arc),
Json(CreateIndexRequest {
id: "demo".to_string(),
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,
extra_skip_dirs: None,
data_file_max_bytes: None,
}),
)
.await;
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let body_bytes = to_bytes(resp.into_body(), 64 * 1024)
.await
.expect("read body");
let body: serde_json::Value = serde_json::from_slice(&body_bytes).expect("valid json");
let err_str = body
.get("error")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert!(
err_str.contains("embedder init failed"),
"expected error message to mention init failure, got: {err_str}",
);
assert!(
err_str.contains("init timed out after 60s"),
"expected recorded timeout message to be surfaced, got: {err_str}",
);
}
#[tokio::test]
async fn install_embedder_clears_previous_error() {
use crate::core::embed::MockEmbedder;
use crate::core::registry::IndexRegistry;
let state = SearchAppState::new(IndexRegistry::new());
state.install_embedder_error("transient hang").await;
assert!(state.current_embedder_error().is_some());
let embedder: Arc<dyn Embedder> = Arc::new(MockEmbedder::new(8));
state.install_embedder(embedder).await;
assert!(state.current_embedder_error().is_none());
assert!(state.is_embedder_ready());
let state_arc = Arc::new(state);
let Json(resp) = health_handler(State(state_arc)).await;
assert_eq!(resp.embedder, "ready");
assert!(resp.embedder_error.is_none());
}
#[tokio::test]
async fn reindex_handler_rejects_within_cooldown() {
use crate::core::{
indexer::CodeIndexer,
registry::{IndexHandle, IndexId, IndexRegistry},
};
use std::sync::Arc;
use tokio::sync::RwLock;
let registry = IndexRegistry::new();
let id = IndexId::new("cooldown-test");
let tmp = tempfile::tempdir().expect("tempdir");
registry.register(IndexHandle::bare(
id.clone(),
Arc::new(RwLock::new(CodeIndexer::new("cooldown-test", tmp.path()))),
tmp.path().to_path_buf(),
));
let state = Arc::new(SearchAppState::new(registry));
state
.last_reindex_aborted_at
.insert(id.clone(), std::time::Instant::now());
let result = reindex_handler(
State(Arc::clone(&state)),
axum::extract::Path("cooldown-test".to_string()),
None,
)
.await;
let err = result.expect_err("expected 429 inside cooldown window");
assert_eq!(err.0, StatusCode::TOO_MANY_REQUESTS);
let body = err.1 .0;
assert!(body.get("retry_after_secs").is_some());
assert!(body.get("hint").is_some());
assert_eq!(body["index_id"], "cooldown-test");
state.last_reindex_aborted_at.remove(&id);
let ok = reindex_handler(
State(Arc::clone(&state)),
axum::extract::Path("cooldown-test".to_string()),
None,
)
.await
.expect("queued");
assert_eq!(ok.0["queued"], serde_json::Value::Bool(true));
}
#[tokio::test]
async fn reindex_status_aborted_memory_serializes_lowercase() {
let status = crate::service::reindex::ReindexStatus::AbortedMemory;
let json = serde_json::to_string(&status).expect("serialize");
assert_eq!(json, "\"abortedmemory\"");
}
#[tokio::test]
async fn index_status_reports_indexing_then_ready() {
use crate::core::{
indexer::CodeIndexer,
registry::{IndexHandle, IndexId, IndexRegistry},
};
use crate::service::reindex::{ReindexProgress, ReindexStatus};
use tokio::sync::RwLock;
let registry = IndexRegistry::new();
let id = IndexId::new("status-test");
let tmp = tempfile::tempdir().expect("tempdir");
registry.register(IndexHandle::bare(
id.clone(),
Arc::new(RwLock::new(CodeIndexer::new("status-test", tmp.path()))),
tmp.path().to_path_buf(),
));
let state = Arc::new(SearchAppState::new(registry));
let Json(idle) = index_status_handler(
State(Arc::clone(&state)),
axum::extract::Path("status-test".to_string()),
)
.await
.expect("status 200");
assert_eq!(idle["status"], "ready", "idle index must report ready");
let progress = Arc::new(ReindexProgress::new()); state.reindex_progress.insert(id.clone(), progress.clone());
let Json(running) = index_status_handler(
State(Arc::clone(&state)),
axum::extract::Path("status-test".to_string()),
)
.await
.expect("status 200");
assert_eq!(
running["status"], "indexing",
"running reindex must report indexing"
);
progress.status.store(ReindexStatus::Complete);
let Json(done) = index_status_handler(
State(Arc::clone(&state)),
axum::extract::Path("status-test".to_string()),
)
.await
.expect("status 200");
assert_eq!(
done["status"], "ready",
"completed reindex must report ready"
);
}
#[tokio::test]
async fn health_includes_resource_fields() {
let state = Arc::new(SearchAppState::new(IndexRegistry::new()));
let Json(resp) = health_handler(State(state)).await;
assert!(resp.rss_mb < 1024 * 1024, "rss_mb unit must be MB");
assert!(resp.cpu_pct >= 0.0, "cpu_pct must be non-negative");
assert_eq!(resp.disk_bytes, 0, "disk ticker has not ticked yet");
let _ = resp.rss_limit_mb;
}
#[tokio::test]
async fn health_non_blocking_when_embedder_slot_write_locked() {
let state = Arc::new(SearchAppState::new(IndexRegistry::new()));
let _write_guard = state.embedder_slot.write().await;
let result = state.try_current_embedder();
assert!(
result.is_none(),
"try_current_embedder must return None when write lock is held"
);
let Json(resp) = health_handler(State(Arc::clone(&state))).await;
assert_eq!(
resp.status, "ok",
"health must return ok even when embedder slot is write-locked"
);
assert!(
resp.embedder_info.is_none(),
"embedder_info must be absent when slot is write-locked (non-blocking fallback)",
);
}
#[tokio::test]
async fn health_includes_embedder_info_when_ready() {
use crate::core::embed::MockEmbedder;
let state = SearchAppState::new(IndexRegistry::new());
let embedder: Arc<dyn Embedder> = Arc::new(MockEmbedder::new(384));
state.install_embedder(embedder).await;
let state_arc = Arc::new(state);
let Json(resp) = health_handler(State(state_arc)).await;
assert_eq!(resp.embedder, "ready");
assert!(
resp.embedder_info.is_some(),
"embedder_info must be present when embedder is installed and slot is uncontended"
);
}
#[test]
fn worker_thread_count_at_least_16() {
use crate::worker_thread_count;
assert_eq!(
worker_thread_count(1),
16,
"worker_thread_count(1) must return 16 (floor enforced)"
);
assert_eq!(
worker_thread_count(32),
32,
"worker_thread_count(32) must return 32 (no artificial cap)"
);
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(worker_thread_count(1))
.enable_all()
.build()
.expect("runtime builder must succeed with worker_thread_count(1) == 16");
drop(rt);
}
#[test]
fn registry_remove_and_get_returns_handle_atomically() {
use crate::core::{
indexer::CodeIndexer,
registry::{IndexHandle, IndexId, IndexRegistry},
};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
let registry = IndexRegistry::new();
let id = IndexId::new("atomic-test");
let root = PathBuf::from("/projects/atomic-test");
registry.register(IndexHandle::bare(
id.clone(),
Arc::new(RwLock::new(CodeIndexer::new("atomic-test", &root))),
root.clone(),
));
let (removed, handle_opt) = registry.remove_and_get(&id);
assert!(removed, "remove_and_get must report the entry existed");
let h = handle_opt.expect("remove_and_get must return the handle when entry exists");
assert_eq!(
h.root_path, root,
"returned handle must carry the correct root_path"
);
assert!(
registry.get(&id).is_none(),
"registry must not contain the entry after remove_and_get"
);
}
#[test]
fn registry_remove_and_get_returns_none_for_missing_id() {
use crate::core::registry::{IndexId, IndexRegistry};
let registry = IndexRegistry::new();
let (removed, got) = registry.remove_and_get(&IndexId::new("ghost-index"));
assert!(!removed, "remove_and_get must return false for unknown id");
assert!(
got.is_none(),
"remove_and_get must return None for unknown id"
);
}