use std::path::{Path, PathBuf};
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use bamboo_plugin::manifest::{McpServerManifestEntry, McpTransportManifest, Platform};
use crate::app_state::AppState;
use super::handlers::{install_plugin, list_plugins, remove_plugin, update_plugin};
fn hello_plugin_example_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../infra/bamboo-plugin/examples/hello-plugin")
}
async fn test_state(data_dir: &Path) -> web::Data<AppState> {
web::Data::new(
AppState::new(data_dir.to_path_buf())
.await
.expect("app state should initialize"),
)
}
macro_rules! plugin_test_app {
($state:expr) => {
App::new()
.app_data($state)
.route("/api/v1/plugins", web::get().to(list_plugins))
.route("/api/v1/plugins/install", web::post().to(install_plugin))
.route("/api/v1/plugins/{id}/update", web::post().to(update_plugin))
.route("/api/v1/plugins/{id}", web::delete().to(remove_plugin))
};
}
fn local_dir_source(path: &Path) -> serde_json::Value {
serde_json::json!({
"source": { "type": "local_dir", "path": path.to_string_lossy() }
})
}
async fn write_bad_manifest_plugin_dir(root: &Path) -> PathBuf {
let dir = root.join("bad-plugin-source");
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(
dir.join("plugin.json"),
serde_json::json!({
"id": "Bad Id!",
"name": "Bad",
"version": "1.0.0"
})
.to_string(),
)
.await
.unwrap();
dir
}
async fn write_mcp_plugin_dir(root: &Path, plugin_id: &str, mcp_id: &str) -> PathBuf {
let dir = root.join(format!("{plugin_id}-source"));
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(
dir.join("plugin.json"),
serde_json::json!({
"id": plugin_id,
"name": "Test Plugin",
"version": "1.0.0",
"provides": {
"mcp_servers": [
{
"id": mcp_id,
"transport": {
"type": "stdio",
"command": "/nonexistent/bamboo-test-mcp-binary-does-not-exist"
}
}
]
}
})
.to_string(),
)
.await
.unwrap();
dir
}
async fn body_json(response: actix_web::dev::ServiceResponse) -> serde_json::Value {
let bytes = test::read_body(response).await;
serde_json::from_slice(&bytes).expect("valid json body")
}
#[actix_web::test]
async fn install_list_reinstall_conflict_then_delete() {
let data_dir = tempfile::tempdir().unwrap();
let state = test_state(data_dir.path()).await;
let app = test::init_service(plugin_test_app!(state.clone())).await;
let req = test::TestRequest::post()
.uri("/api/v1/plugins/install")
.set_json(local_dir_source(&hello_plugin_example_dir()))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::CREATED);
let view = body_json(resp).await;
assert_eq!(view["id"], "hello-plugin");
assert_eq!(view["name"], "Hello Plugin");
assert_eq!(view["version"], "0.1.0");
assert_eq!(view["status"], "installed");
assert_eq!(view["source"]["type"], "local_dir");
assert_eq!(
view["registered"]["skill_dirs"],
serde_json::json!(["hello-world"])
);
assert_eq!(
view["registered"]["preset_ids"],
serde_json::json!(["hello_plugin_greeter"])
);
let req = test::TestRequest::get().uri("/api/v1/plugins").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let listed = body_json(resp).await;
let plugins = listed["plugins"].as_array().expect("plugins array");
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0]["id"], "hello-plugin");
assert_eq!(plugins[0]["name"], "Hello Plugin");
let req = test::TestRequest::post()
.uri("/api/v1/plugins/install")
.set_json(local_dir_source(&hello_plugin_example_dir()))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::CONFLICT);
let error = body_json(resp).await;
assert!(
error["error"]
.as_str()
.unwrap()
.contains("already installed"),
"error message should mention already installed: {error}"
);
let req = test::TestRequest::delete()
.uri("/api/v1/plugins/hello-plugin")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let deleted = body_json(resp).await;
assert_eq!(deleted["id"], "hello-plugin");
assert_eq!(deleted["removed"], true);
let req = test::TestRequest::get().uri("/api/v1/plugins").to_request();
let resp = test::call_service(&app, req).await;
let listed = body_json(resp).await;
assert!(listed["plugins"].as_array().unwrap().is_empty());
assert!(hello_plugin_example_dir().join("plugin.json").exists());
}
#[actix_web::test]
async fn install_with_invalid_manifest_returns_400() {
let data_dir = tempfile::tempdir().unwrap();
let state = test_state(data_dir.path()).await;
let app = test::init_service(plugin_test_app!(state.clone())).await;
let plugin_source_dir = write_bad_manifest_plugin_dir(data_dir.path()).await;
let req = test::TestRequest::post()
.uri("/api/v1/plugins/install")
.set_json(local_dir_source(&plugin_source_dir))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let error = body_json(resp).await;
assert!(
error["error"].as_str().unwrap().contains("invalid"),
"error message should mention the manifest is invalid: {error}"
);
}
#[actix_web::test]
async fn install_with_foreign_mcp_conflict_returns_409() {
let data_dir = tempfile::tempdir().unwrap();
let state = test_state(data_dir.path()).await;
let user_entry = McpServerManifestEntry {
id: "shared-tool".to_string(),
name: None,
enabled: false,
transport: McpTransportManifest::Stdio {
command: "/usr/bin/true".to_string(),
args: vec![],
cwd: None,
env: Default::default(),
},
allowed_tools: vec![],
denied_tools: vec![],
};
let user_server = user_entry
.resolve(
Path::new("/tmp"),
"not-a-plugin",
Platform::current().unwrap_or(Platform::Linux),
)
.expect("resolve a user mcp server config");
state
.update_config(
move |cfg| {
cfg.mcp.servers.push(user_server.clone());
Ok(())
},
Default::default(),
)
.await
.expect("seed user mcp server");
let app = test::init_service(plugin_test_app!(state.clone())).await;
let plugin_source_dir =
write_mcp_plugin_dir(data_dir.path(), "conflicting-plugin", "shared-tool").await;
let req = test::TestRequest::post()
.uri("/api/v1/plugins/install")
.set_json(local_dir_source(&plugin_source_dir))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::CONFLICT);
let error = body_json(resp).await;
let message = error["error"].as_str().unwrap();
assert!(message.contains("mcp server"), "{message}");
assert!(message.contains("shared-tool"), "{message}");
let req = test::TestRequest::get().uri("/api/v1/plugins").to_request();
let resp = test::call_service(&app, req).await;
let listed = body_json(resp).await;
assert!(listed["plugins"].as_array().unwrap().is_empty());
let config = state.config.read().await;
let servers: Vec<_> = config
.mcp
.servers
.iter()
.filter(|s| s.id == "shared-tool")
.collect();
assert_eq!(servers.len(), 1);
assert!(!servers[0].enabled);
}
#[actix_web::test]
async fn update_upgrades_an_installed_plugin() {
let data_dir = tempfile::tempdir().unwrap();
let state = test_state(data_dir.path()).await;
let app = test::init_service(plugin_test_app!(state.clone())).await;
let req = test::TestRequest::post()
.uri("/api/v1/plugins/install")
.set_json(local_dir_source(&hello_plugin_example_dir()))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::CREATED);
let req = test::TestRequest::post()
.uri("/api/v1/plugins/hello-plugin/update")
.set_json(local_dir_source(&hello_plugin_example_dir()))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let view = body_json(resp).await;
assert_eq!(view["id"], "hello-plugin");
assert_eq!(view["status"], "installed");
}
#[actix_web::test]
async fn update_with_mismatched_path_id_returns_400_and_rolls_back() {
let data_dir = tempfile::tempdir().unwrap();
let state = test_state(data_dir.path()).await;
let app = test::init_service(plugin_test_app!(state.clone())).await;
let req = test::TestRequest::post()
.uri("/api/v1/plugins/some-other-id/update")
.set_json(local_dir_source(&hello_plugin_example_dir()))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let error = body_json(resp).await;
let message = error["error"].as_str().unwrap();
assert!(message.contains("some-other-id"), "{message}");
assert!(message.contains("hello-plugin"), "{message}");
let req = test::TestRequest::get().uri("/api/v1/plugins").to_request();
let resp = test::call_service(&app, req).await;
let listed = body_json(resp).await;
assert!(listed["plugins"].as_array().unwrap().is_empty());
}
#[actix_web::test]
async fn delete_unknown_id_returns_404() {
let data_dir = tempfile::tempdir().unwrap();
let state = test_state(data_dir.path()).await;
let app = test::init_service(plugin_test_app!(state.clone())).await;
let req = test::TestRequest::delete()
.uri("/api/v1/plugins/does-not-exist")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}