#[cfg(test)]
pub(crate) async fn create_test_state_with_capacity(
workspace: &std::path::Path,
queue_capacity: usize,
) -> (
Arc<AppState>,
tokio::sync::mpsc::Receiver<crate::models::IndexJob>,
) {
use knot::db::graph::ConnectExt;
use knot::db::vector::VectorConnectExt;
use std::collections::HashMap;
use std::sync::Mutex;
let registry = Registry::load_or_create(workspace).expect("Failed to create test registry");
let graph_db =
knot::db::graph::GraphDb::connect("bolt://localhost:9999", "neo4j", "badpassword")
.await
.expect("connect for test db should work");
let vector_db =
knot::db::vector::VectorDb::connect("http://localhost:9999", "test_collection", 384)
.await
.expect("connect for test vector db should work");
let (job_tx, job_rx) = tokio::sync::mpsc::channel::<crate::models::IndexJob>(queue_capacity);
(
Arc::new(AppState {
vector_db: Arc::new(vector_db),
graph_db: Arc::new(graph_db),
embedder: None,
workspace_dir: workspace.to_string_lossy().into(),
registry: Arc::new(Mutex::new(registry)),
job_tx,
qdrant_url: "http://localhost:6334".into(),
qdrant_collection: "knot_entities".into(),
neo4j_uri: "bolt://localhost:7687".into(),
neo4j_user: "neo4j".into(),
neo4j_password: "secret".into(),
embed_dim: 384,
rayon_threads: None,
batch_size: 64,
ingest_concurrency: 4,
start_time: std::time::Instant::now(),
progress_trackers: Arc::new(Mutex::new(HashMap::new())),
}),
job_rx,
)
}
#[cfg(test)]
pub(crate) async fn create_test_state_with_rx(
workspace: &std::path::Path,
) -> (
Arc<AppState>,
tokio::sync::mpsc::Receiver<crate::models::IndexJob>,
) {
create_test_state_with_capacity(workspace, 16).await
}
#[cfg(test)]
pub(crate) fn make_test_repo(
id: &str,
local_path: &std::path::Path,
status: crate::models::RepoStatus,
) -> crate::models::RepoEntry {
crate::models::RepoEntry {
id: id.into(),
url: format!("https://invalid.example/{id}.git"),
local_path: local_path.to_string_lossy().into(),
auth_type: crate::models::AuthType::Ssh,
branch: "main".into(),
webhook_secret: None,
last_indexed: None,
status,
}
}
#[cfg(test)]
use crate::models::AppState;
#[cfg(test)]
use crate::registry::Registry;
#[cfg(test)]
use std::sync::Arc;
#[cfg(test)]
mod tests {
use super::*;
use crate::handlers::graph_parse::*;
use crate::handlers::models::*;
use crate::handlers::*;
use crate::models::RegisterRepoResponse;
use axum::Router;
use axum::body::Body;
use axum::http::Request;
use axum::http::StatusCode;
use axum::routing::{get, post};
use tempfile::TempDir;
use tower::ServiceExt;
fn build_test_app(state: Arc<AppState>) -> Router {
Router::new()
.route(
"/api/repos",
get(list_repos_handler).post(register_repo_handler),
)
.route(
"/api/repos/{id}",
get(get_repo_handler).delete(delete_repo_handler),
)
.route("/api/repos/{id}/search", get(search_handler))
.route("/api/repos/{id}/callers", get(callers_handler))
.route("/api/search", get(search_all_handler))
.route("/api/callers", get(callers_all_handler))
.route("/api/repos/{id}/explore", get(explore_handler))
.route("/api/repos/{id}/deps", get(deps_handler))
.route("/api/repos/{id}/graph", get(graph_handler))
.route("/api/repos/{id}/graph/expand", get(graph_expand_handler))
.route("/api/repos/{id}/graph/repos", get(repo_graph_handler))
.route("/api/repos/{id}/sync", post(sync_repo_handler))
.route("/api/webhook/{id}", post(webhook_handler))
.route("/api/health", get(health_handler))
.route("/graph", get(graph_viewer_handler))
.with_state(state)
}
async fn post_repo(app: Router, body: &serde_json::Value) -> axum::response::Response {
app.oneshot(
Request::post("/api/repos")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(body).unwrap()))
.unwrap(),
)
.await
.unwrap()
}
async fn create_test_state_with_tempdir(
temp_dir: &TempDir,
) -> (
Arc<AppState>,
tokio::sync::mpsc::Receiver<crate::models::IndexJob>,
) {
create_test_state_with_rx(temp_dir.path()).await
}
#[tokio::test]
async fn test_register_duplicate_is_idempotent() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let body = serde_json::json!({
"url": "git@github.com:org/repo.git",
"auth_type": "ssh"
});
let resp1 = post_repo(app.clone(), &body).await;
assert_eq!(resp1.status(), StatusCode::ACCEPTED);
let body1 = axum::body::to_bytes(resp1.into_body(), 1024 * 1024)
.await
.unwrap();
let json1: serde_json::Value = serde_json::from_slice(&body1).unwrap();
assert_eq!(json1["message"], "Repository registered successfully");
let resp2 = post_repo(app, &body).await;
assert_eq!(resp2.status(), StatusCode::ACCEPTED);
let body2 = axum::body::to_bytes(resp2.into_body(), 1024 * 1024)
.await
.unwrap();
let json2: serde_json::Value = serde_json::from_slice(&body2).unwrap();
assert_eq!(json2["message"], "Repository re-registered successfully");
}
#[tokio::test]
async fn test_delete_nonexistent_repo_returns_404() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let response = app
.oneshot(
Request::delete("/api/repos/nonexistent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_get_nonexistent_repo_returns_404() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let response = app
.oneshot(
Request::get("/api/repos/ghost")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_sync_nonexistent_repo_returns_404() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let response = app
.oneshot(
Request::post("/api/repos/ghost/sync")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_sync_existing_repo_returns_202() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let body = serde_json::json!({
"url": "git@github.com:org/sync-test.git",
"auth_type": "ssh"
});
let reg_response = post_repo(app.clone(), &body).await;
assert_eq!(reg_response.status(), StatusCode::ACCEPTED);
let body_bytes = axum::body::to_bytes(reg_response.into_body(), 1024 * 1024)
.await
.unwrap();
let reg_json: RegisterRepoResponse = serde_json::from_slice(&body_bytes).unwrap();
let response = app
.oneshot(
Request::post(format!("/api/repos/{}/sync", reg_json.id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let _body_bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap();
assert_eq!(status, StatusCode::ACCEPTED);
}
#[tokio::test]
async fn test_webhook_nonexistent_repo_no_auth_returns_404() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let response = app
.oneshot(
Request::post("/api/webhook/test-repo")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_webhook_nonexistent_repo_returns_404() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let response = app
.oneshot(
Request::post("/api/webhook/ghost")
.header("content-type", "application/json")
.header("X-Gitlab-Token", "test-token")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_health_returns_ok() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let response = app
.oneshot(Request::get("/api/health").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body_bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap();
let health: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(health["status"], "ok");
assert!(health["uptime_seconds"].as_u64().is_some());
assert!(health["repositories_total"].as_u64().is_some());
}
#[tokio::test]
async fn test_graph_missing_entity_returns_overview_error() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let body = serde_json::json!({
"url": "git@github.com:org/repo.git",
"auth_type": "ssh"
});
let res = post_repo(app.clone(), &body).await;
let body_bytes = axum::body::to_bytes(res.into_body(), 1024 * 1024)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
let repo_id = json["id"].as_str().unwrap();
let response = app
.oneshot(
Request::get(format!("/api/repos/{repo_id}/graph"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn test_graph_expand_missing_entity_returns_400() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let body = serde_json::json!({
"url": "git@github.com:org/repo.git",
"auth_type": "ssh"
});
let res = post_repo(app.clone(), &body).await;
let body_bytes = axum::body::to_bytes(res.into_body(), 1024 * 1024)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
let repo_id = json["id"].as_str().unwrap();
let response = app
.oneshot(
Request::get(format!("/api/repos/{repo_id}/graph/expand"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_graph_viewer_returns_200() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let response = app
.oneshot(Request::get("/graph").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"text/html; charset=utf-8"
);
let body_bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap();
let body = String::from_utf8(body_bytes.to_vec()).unwrap();
assert!(body.contains("<!DOCTYPE html>"));
assert!(body.contains("ForceGraph3D"));
assert!(
!body.contains("{{KNOT_SERVER_VERSION}}"),
"version placeholder was not substituted"
);
assert!(
body.contains(env!("CARGO_PKG_VERSION")),
"package version absent from HTML"
);
assert!(body.contains("knot-server v"), "version badge malformed");
}
#[tokio::test]
async fn test_graph_nonexistent_repo_returns_404() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let response = app
.oneshot(
Request::get("/api/repos/nonexistent/graph?entity=some_function")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_graph_expand_nonexistent_repo_returns_404() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let response = app
.oneshot(
Request::get("/api/repos/nonexistent/graph/expand?entity=some_function")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_register_returns_429_when_queue_full() {
let dir = TempDir::new().unwrap();
let workspace = dir.path().to_owned();
let workspace2 = workspace.join("ws2");
std::fs::create_dir_all(&workspace2).unwrap();
let (state2, mut small_rx) = create_test_state_with_capacity(&workspace2, 1).await;
let app = build_test_app(state2);
let body = serde_json::json!({
"url": "git@github.com:org/foo.git",
"auth_type": "ssh"
});
let resp1 = post_repo(app.clone(), &body).await;
assert_eq!(resp1.status(), StatusCode::ACCEPTED);
let body2 = serde_json::json!({
"url": "git@github.com:org/bar.git",
"auth_type": "ssh"
});
let resp2 = post_repo(app, &body2).await;
assert_eq!(resp2.status(), StatusCode::TOO_MANY_REQUESTS);
let _ = small_rx.try_recv();
}
#[tokio::test]
async fn test_search_missing_query_returns_400() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let response = app
.oneshot(
Request::get("/api/repos/any-repo/search")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_callers_missing_entity_returns_400() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let response = app
.oneshot(
Request::get("/api/repos/any-repo/callers")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_explore_missing_path_returns_400() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let response = app
.oneshot(
Request::get("/api/repos/any-repo/explore")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_list_repos_returns_empty() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let response = app
.oneshot(Request::get("/api/repos").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body_bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert!(json["repositories"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn test_graph_invalid_relationship_returns_400() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let body = serde_json::json!({
"url": "git@github.com:org/repo.git",
"auth_type": "ssh"
});
let res = post_repo(app.clone(), &body).await;
let body_bytes = axum::body::to_bytes(res.into_body(), 1024 * 1024)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
let repo_id = json["id"].as_str().unwrap();
let response = app
.oneshot(
Request::get(format!("/api/repos/{repo_id}/graph?relationships=INVALID"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_graph_invalid_kind_returns_400() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let body = serde_json::json!({
"url": "git@github.com:org/repo.git",
"auth_type": "ssh"
});
let res = post_repo(app.clone(), &body).await;
let body_bytes = axum::body::to_bytes(res.into_body(), 1024 * 1024)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
let repo_id = json["id"].as_str().unwrap();
let response = app
.oneshot(
Request::get(format!("/api/repos/{repo_id}/graph?kinds=INVALID"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_graph_expand_invalid_kind_returns_400() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let body = serde_json::json!({
"url": "git@github.com:org/repo.git",
"auth_type": "ssh"
});
let res = post_repo(app.clone(), &body).await;
let body_bytes = axum::body::to_bytes(res.into_body(), 1024 * 1024)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
let repo_id = json["id"].as_str().unwrap();
let response = app
.oneshot(
Request::get(format!(
"/api/repos/{repo_id}/graph/expand?entity=test&kinds=INVALID"
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_graph_subgraph_invalid_kind_returns_400() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let body = serde_json::json!({
"url": "git@github.com:org/repo.git",
"auth_type": "ssh"
});
let res = post_repo(app.clone(), &body).await;
let body_bytes = axum::body::to_bytes(res.into_body(), 1024 * 1024)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
let repo_id = json["id"].as_str().unwrap();
let response = app
.oneshot(
Request::get(format!(
"/api/repos/{repo_id}/graph?entity=some_func&kinds=INVALID"
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_register_repo_empty_url() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let body = serde_json::json!({
"url": " ",
"auth_type": "ssh"
});
let res = post_repo(app.clone(), &body).await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_register_repo_invalid_url_empty_id() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let body = serde_json::json!({
"url": "/",
"auth_type": "ssh"
});
let res = post_repo(app.clone(), &body).await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn test_parse_kinds_empty_uses_default() {
let result = parse_kinds("").unwrap();
assert!(!result.is_empty());
assert!(result.contains(&"class"));
assert!(result.contains(&"interface"));
assert!(result.contains(&"rust_struct"));
assert!(result.contains(&"rust_trait"));
assert!(!result.contains(&"function"));
}
#[test]
fn test_parse_kinds_invalid_returns_error() {
let result = parse_kinds("INVALID");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid kind category"));
}
#[test]
fn test_parse_kinds_classes_expands() {
let result = parse_kinds("classes").unwrap();
assert!(!result.is_empty());
for k in KIND_CATEGORY_CLASSES {
assert!(result.contains(k), "missing kind: {}", k);
}
}
#[test]
fn test_parse_kinds_interfaces_expands() {
let result = parse_kinds("interfaces").unwrap();
assert!(!result.is_empty());
for k in KIND_CATEGORY_INTERFACES {
assert!(result.contains(k), "missing kind: {}", k);
}
}
#[test]
fn test_parse_kinds_functions_expands() {
let result = parse_kinds("functions").unwrap();
assert!(!result.is_empty());
for k in KIND_CATEGORY_FUNCTIONS {
assert!(result.contains(k), "missing kind: {}", k);
}
}
#[test]
fn test_parse_kinds_other_returns_empty_visible() {
let result = parse_kinds("other").unwrap();
assert!(result.is_empty());
}
#[test]
fn test_parse_kinds_multiple_categories() {
let result = parse_kinds("classes,interfaces,other").unwrap();
assert!(result.contains(&"class"));
assert!(result.contains(&"interface"));
assert!(!result.contains(&"function"));
}
#[test]
fn test_includes_other_empty_default() {
assert!(!includes_other(""));
}
#[test]
fn test_includes_other_explicit() {
assert!(includes_other("other"));
}
#[test]
fn test_includes_other_not_present() {
assert!(!includes_other("classes"));
assert!(!includes_other("classes,interfaces"));
assert!(!includes_other("functions"));
}
#[test]
fn test_includes_other_mixed() {
assert!(includes_other("classes,other"));
assert!(includes_other("classes,interfaces,other"));
}
#[tokio::test]
async fn test_repo_graph_handler_not_found() {
let temp_dir = TempDir::new().unwrap();
let (state, _rx) = create_test_state_with_tempdir(&temp_dir).await;
let app = build_test_app(state);
let response = app
.oneshot(
Request::get("/api/repos/non-existent-repo/graph/repos")
.header("content-type", "application/json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_repo_graph_handler_invalid_direction() {
let temp_dir = TempDir::new().unwrap();
let (state, _rx) = create_test_state_with_tempdir(&temp_dir).await;
{
let mut registry = state.registry.lock().unwrap();
registry
.add_or_replace(crate::models::RepoEntry {
id: "test-repo".to_string(),
url: "/path/to/test-repo".to_string(),
auth_type: crate::models::AuthType::Ssh,
local_path: "/path/to/test-repo".to_string(),
branch: "main".to_string(),
webhook_secret: None,
last_indexed: None,
status: crate::models::RepoStatus::Indexed,
})
.unwrap();
}
let app = build_test_app(state);
let response = app
.oneshot(
Request::get("/api/repos/test-repo/graph/repos?direction=invalid")
.header("content-type", "application/json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
async fn get_error(app: Router, uri: &str) -> (StatusCode, String) {
let response = app
.oneshot(Request::get(uri).body(Body::empty()).unwrap())
.await
.unwrap();
let status = response.status();
let body_bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap();
(status, String::from_utf8(body_bytes.to_vec()).unwrap())
}
async fn add_registry_entry(state: &Arc<AppState>, id: &str) {
let mut registry = state.registry.lock().unwrap();
registry
.add_or_replace(crate::models::RepoEntry {
id: id.to_string(),
url: format!("/path/to/{id}"),
auth_type: crate::models::AuthType::Ssh,
local_path: format!("/path/to/{id}"),
branch: "main".to_string(),
webhook_secret: None,
last_indexed: None,
status: crate::models::RepoStatus::Indexed,
})
.unwrap();
}
#[tokio::test]
async fn search_all_missing_q_returns_400() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let (status, body) = get_error(app, "/api/search").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("'q'"), "body must mention 'q': {body}");
}
#[tokio::test]
async fn search_all_blank_q_returns_400() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let (status, body) = get_error(app, "/api/search?q=%20%20").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("'q'"), "body must mention 'q': {body}");
}
#[tokio::test]
async fn search_all_unknown_repo_returns_400() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let (status, body) = get_error(app, "/api/search?q=x&repo=ghost").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("ghost"), "body must list the id: {body}");
}
#[tokio::test]
async fn search_all_unknown_repo_checked_before_embedder() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let (status, _body) = get_error(app, "/api/search?q=x&repo=ghost").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn populated_registry_reaches_the_db_layer() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
add_registry_entry(&state, "real-repo").await;
let app = build_test_app(state);
let (search_status, search_body) = get_error(app.clone(), "/api/search?q=x").await;
assert_eq!(search_status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(
search_body.contains("Embedding model not initialized"),
"populated registry must reach the embedder step: {search_body}"
);
let (callers_status, callers_body) = get_error(app, "/api/callers?entity=x").await;
assert_eq!(callers_status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(
callers_body.contains("Find callers failed"),
"populated registry must reach the graph DB: {callers_body}"
);
}
#[tokio::test]
async fn search_all_with_empty_registry_returns_empty_array() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let (status, body) = get_error(app, "/api/search?q=x&max_results=100").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body.trim(),
"[]",
"empty registry must yield a bare empty array: {body}"
);
}
#[tokio::test]
async fn callers_all_with_empty_registry_returns_empty_buckets() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let (status, body) = get_error(app, "/api/callers?entity=GhostUtil.work").await;
assert_eq!(status, StatusCode::OK);
let json: serde_json::Value = serde_json::from_str(&body)
.unwrap_or_else(|e| panic!("empty-registry body must be JSON: {e}\n{body}"));
for bucket in [
"calls",
"extends",
"implements",
"overridden_by",
"overrides",
"references",
] {
assert_eq!(
json[bucket],
serde_json::json!([]),
"bucket '{bucket}' must be an empty array"
);
}
assert_eq!(json["resolution"]["fuzzy"], false);
assert_eq!(json["resolution"]["query"], "GhostUtil.work");
assert_eq!(json["resolution"]["targets"], serde_json::json!([]));
assert_eq!(json["resolution"]["tier"], "none");
assert_eq!(json["resolution"]["truncated"], false);
}
#[tokio::test]
async fn empty_registry_response_preserves_validation_order() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let (search_status, search_body) = get_error(app.clone(), "/api/search").await;
assert_eq!(search_status, StatusCode::BAD_REQUEST);
assert!(
search_body.contains("'q'"),
"body must mention 'q': {search_body}"
);
let (callers_status, callers_body) = get_error(app, "/api/callers").await;
assert_eq!(callers_status, StatusCode::BAD_REQUEST);
assert!(
callers_body.contains("'entity'"),
"body must mention 'entity': {callers_body}"
);
}
#[tokio::test]
async fn unknown_repo_still_400_on_empty_registry() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let (search_status, search_body) =
get_error(app.clone(), "/api/search?q=x&repo=ghost").await;
let (callers_status, callers_body) =
get_error(app, "/api/callers?entity=x&repo=ghost").await;
assert_eq!(search_status, StatusCode::BAD_REQUEST);
assert_eq!(callers_status, StatusCode::BAD_REQUEST);
assert_eq!(
search_body, callers_body,
"both routes must emit a byte-identical 400 for repo=ghost"
);
assert!(
search_body.contains("ghost"),
"body must list the id: {search_body}"
);
}
#[tokio::test]
async fn search_all_known_repo_passes_validation() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
add_registry_entry(&state, "real-repo").await;
let app = build_test_app(state);
let (status, body) = get_error(app, "/api/search?q=x&repo=real-repo").await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(
body.contains("Embedding model not initialized"),
"known repo must pass scope validation: {body}"
);
}
#[tokio::test]
async fn per_repo_search_route_unchanged() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let (status, _body) = get_error(app, "/api/repos/ghost/search?q=x").await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn callers_all_missing_entity_returns_400() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let (status, body) = get_error(app, "/api/callers").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body.contains("'entity'"),
"body must mention 'entity': {body}"
);
}
#[tokio::test]
async fn callers_all_blank_entity_returns_400() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let (status, body) = get_error(app, "/api/callers?entity=%20%20").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("'entity'"), "{body}");
}
#[tokio::test]
async fn callers_all_unknown_repo_returns_400() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let (status, body) = get_error(app, "/api/callers?entity=x&repo=ghost").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.contains("ghost"), "body must list the id: {body}");
}
#[tokio::test]
async fn callers_all_unknown_repo_checked_before_db_call() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let (status, _body) = get_error(app, "/api/callers?entity=x&repo=ghost").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn callers_all_valid_scope_reaches_graph_db() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
add_registry_entry(&state, "real-repo").await;
let app = build_test_app(state);
let (status, body) = get_error(app, "/api/callers?entity=x").await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(
!body.contains("Embedding model not initialized"),
"callers must not run an embedder check: {body}"
);
assert!(
body.contains("Find callers failed"),
"body must surface the knot/DB failure: {body}"
);
}
#[tokio::test]
async fn callers_all_known_repo_passes_validation() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
add_registry_entry(&state, "real-repo").await;
let app = build_test_app(state);
let (status, body) = get_error(app, "/api/callers?entity=x&repo=real-repo").await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(body.contains("Find callers failed"), "{body}");
}
#[tokio::test]
async fn callers_all_and_search_all_share_scope_errors() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let (_, search_body) = get_error(app.clone(), "/api/search?q=x&repo=z,ghost,ghost").await;
let (_, callers_body) = get_error(app, "/api/callers?entity=x&repo=z,ghost,ghost").await;
assert_eq!(
search_body, callers_body,
"both routes must emit a byte-identical 400 for the same bad repo value"
);
}
#[tokio::test]
async fn per_repo_callers_route_unchanged() {
let dir = TempDir::new().unwrap();
let (state, _job_rx) = create_test_state_with_tempdir(&dir).await;
let app = build_test_app(state);
let (status, _body) = get_error(app, "/api/repos/ghost/callers?entity=x").await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
}
}