use mcpmesh_node::{NodeBuilder, StartError};
#[tokio::test(flavor = "multi_thread")]
async fn a_node_starts_in_an_empty_root_and_answers_status() {
let root = tempfile::tempdir().unwrap();
let node = NodeBuilder::new(root.path()).start().await.expect("start");
let mut control = node.control().await.expect("control");
let status = control.status().await.expect("status");
assert_eq!(status.stack_version, mcpmesh_node::VERSION);
assert!(status.services.is_empty());
node.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn set_nickname_renames_live_and_persists() {
let root = tempfile::tempdir().unwrap();
let node = NodeBuilder::new(root.path()).start().await.expect("start");
let mut control = node.control().await.expect("control");
control.set_nickname("workbench").await.expect("rename");
let status = control.status().await.expect("status");
assert_eq!(status.self_nickname, "workbench");
control
.register_service_with(
"notes",
mcpmesh_local_api::BackendSpec::Socket {
path: root.path().join("notes.sock").display().to_string(),
},
vec![],
true,
)
.await
.expect("register ephemeral service");
let invite = control.invite(vec!["notes".into()]).await.expect("invite");
let decoded = mcpmesh_node::pairing::Invite::decode(&invite.invite_line).expect("decode");
assert_eq!(decoded.nickname, "workbench");
let cfg_text = std::fs::read_to_string(root.path().join("config/config.toml")).unwrap();
assert!(
cfg_text.contains("nickname = \"workbench\""),
"config must carry the rename: {cfg_text}"
);
for bad in ["", " ", "a/b"] {
control
.set_nickname(bad)
.await
.expect_err("invalid nickname must be refused");
}
let status = control.status().await.expect("status after refusals");
assert_eq!(status.self_nickname, "workbench");
node.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn shutdown_frees_the_root_even_with_a_live_subscription_attached() {
let root = tempfile::tempdir().unwrap();
let node = NodeBuilder::new(root.path()).start().await.expect("start");
let control = node.control().await.expect("control");
let _sub = control.subscribe().await.expect("subscribe");
tokio::time::timeout(std::time::Duration::from_secs(5), node.shutdown())
.await
.expect("shutdown must complete promptly even with a live subscription attached");
let restarted = tokio::time::timeout(
std::time::Duration::from_secs(5),
NodeBuilder::new(root.path()).start(),
)
.await
.expect("restart must not hang")
.expect("restart must succeed once the old node's resources are released");
restarted.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn a_second_node_on_the_same_root_is_refused() {
let root = tempfile::tempdir().unwrap();
let first = NodeBuilder::new(root.path()).start().await.expect("first");
let err = NodeBuilder::new(root.path())
.start()
.await
.expect_err("second node on the same root must refuse");
assert!(
matches!(err, StartError::DataDirInUse { .. }),
"want DataDirInUse, got: {err:?}"
);
first.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn a_configured_gc_interval_reaches_the_store_and_still_frees_the_root() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("config")).unwrap();
std::fs::write(
root.path().join("config/config.toml"),
"[blobs]\ngc_interval = \"60s\"\n",
)
.unwrap();
let node = NodeBuilder::new(root.path()).start().await.expect("start");
let mut control = node.control().await.expect("control");
let gc = control
.status()
.await
.expect("status")
.storage
.expect("storage block")
.blobs_gc
.expect("a configured gc_interval must reach the store and be reported");
assert_eq!(
gc.interval_secs, 60,
"the reported interval must be the one the store is actually on"
);
assert_eq!(
gc.runs, 0,
"the collector sleeps a full interval before its first run — 0 here is correct, and is why \
`Some` with runs: 0 has to be distinguishable from absent"
);
tokio::time::timeout(std::time::Duration::from_secs(10), node.shutdown())
.await
.expect("shutdown must complete promptly on a collecting node");
let restarted = tokio::time::timeout(
std::time::Duration::from_secs(10),
NodeBuilder::new(root.path()).start(),
)
.await
.expect("restart must not hang — a collecting node must release its blob store")
.expect("restart must succeed");
restarted.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn no_gc_interval_means_no_collector_reported() {
let root = tempfile::tempdir().unwrap();
let node = NodeBuilder::new(root.path()).start().await.expect("start");
let mut control = node.control().await.expect("control");
assert!(
control
.status()
.await
.expect("status")
.storage
.expect("storage block")
.blobs_gc
.is_none(),
"an unconfigured node must report NO collector — the default, and every release <= 0.42.0"
);
node.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn an_unparseable_gc_interval_boots_with_collection_off() {
for bad in ["1hh", "30s"] {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("config")).unwrap();
std::fs::write(
root.path().join("config/config.toml"),
format!("[blobs]\ngc_interval = \"{bad}\"\n"),
)
.unwrap();
let node = NodeBuilder::new(root.path()).start().await.expect("start");
let mut control = node.control().await.expect("control");
assert!(
control
.status()
.await
.expect("status")
.storage
.expect("storage block")
.blobs_gc
.is_none(),
"{bad:?} must leave collection OFF — a knob that deletes bytes must not start on a typo"
);
node.shutdown().await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_configured_local_discovery_mode_reaches_the_node_and_is_reported() {
for mode in ["on", "resolve", "off"] {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("config")).unwrap();
std::fs::write(
root.path().join("config/config.toml"),
format!("[network]\nrelay_mode = \"disabled\"\nlocal_discovery = \"{mode}\"\n"),
)
.unwrap();
let node = NodeBuilder::new(root.path()).start().await.expect("start");
let mut control = node.control().await.expect("control");
let reported = control
.status()
.await
.expect("status")
.self_network
.expect("self_network block")
.local_discovery
.expect("api_minor >= 50 always reports the mode");
assert_eq!(
reported, mode,
"the reported mode must be the one written in config, in the SAME vocabulary — a \
read-back an operator cannot match against their own file confirms nothing"
);
node.shutdown().await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn local_discovery_is_off_on_a_node_that_never_configured_it() {
let root = tempfile::tempdir().unwrap();
let node = NodeBuilder::new(root.path()).start().await.expect("start");
let mut control = node.control().await.expect("control");
assert_eq!(
control
.status()
.await
.expect("status")
.self_network
.expect("self_network")
.local_discovery
.as_deref(),
Some("off"),
"the default must be OFF — an upgrade must never put a node on the air"
);
node.shutdown().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn an_unknown_local_discovery_refuses_to_boot() {
for bad in ["resolv", "of", "true"] {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("config")).unwrap();
std::fs::write(
root.path().join("config/config.toml"),
format!("[network]\nlocal_discovery = \"{bad}\"\n"),
)
.unwrap();
let e = NodeBuilder::new(root.path())
.start()
.await
.err()
.unwrap_or_else(|| panic!("{bad:?} must refuse the boot rather than default"));
let msg = format!("{e:#}");
assert!(
msg.contains("local_discovery"),
"the refusal must name the knob: {msg}"
);
}
}