use std::future::IntoFuture as _;
use super::*;
fn row(id: &str, name: &str, verdict: Option<&str>) -> SessionVerdict {
SessionVerdict {
id: id.to_string(),
name: name.to_string(),
verdict: verdict.map(str::to_string),
}
}
#[test]
fn build_plan_maps_each_verdict() {
let rows = vec![
row("11111111-a", "alpha", Some("idle")),
row("22222222-b", "bravo", Some("done")),
row("33333333-c", "charlie", Some("working")),
row("44444444-d", "delta", None),
];
let plan = build_plan(&rows);
assert_eq!(plan[0].action, PruneAction::Stop);
assert_eq!(plan[1].action, PruneAction::Decommission);
assert!(matches!(plan[2].action, PruneAction::Skip(_)));
assert!(matches!(plan[3].action, PruneAction::Skip(_)));
assert_eq!(plan[3].verdict, "none");
}
#[test]
fn build_plan_preserves_order() {
let rows = vec![
row("aaaa-1", "a", Some("idle")),
row("bbbb-2", "b", Some("idle")),
];
let plan = build_plan(&rows);
assert_eq!(plan[0].id, "aaaa-1");
assert_eq!(plan[1].id, "bbbb-2");
}
#[test]
fn build_plan_dry_run_matches_live_plan() {
let rows = vec![
row("1-a", "a", Some("idle")),
row("2-b", "b", Some("done")),
row("3-c", "c", Some("errored")),
];
let dry = build_plan(&rows);
let live = build_plan(&rows);
let labels = |p: &[PlannedAction]| p.iter().map(|x| x.action.label()).collect::<Vec<_>>();
assert_eq!(labels(&dry), labels(&live));
assert_eq!(actionable_count(&dry), 2);
}
#[test]
fn actionable_count_excludes_skips() {
let rows = vec![
row("1", "a", Some("idle")),
row("2", "b", Some("working")),
row("3", "c", Some("done")),
];
assert_eq!(actionable_count(&build_plan(&rows)), 2);
}
#[test]
fn render_plan_text_lists_actions() {
let rows = vec![
row("11111111-aaaa", "alpha", Some("idle")),
row("22222222-bbbb", "bravo", Some("working")),
];
let out = render_plan_text(&build_plan(&rows), true);
assert!(out.contains("stop"));
assert!(out.contains("alpha"));
assert!(out.contains("11111111"));
assert!(out.contains("skip"));
assert!(out.contains("bravo"));
assert!(out.contains("dry run"));
assert!(out.contains("would act on 1 of 2"));
}
#[test]
fn render_plan_text_empty() {
assert_eq!(
render_plan_text(&[], true),
"no managed sessions to prune\n"
);
}
#[test]
fn render_plan_json_shape() {
let rows = vec![
row("1-a", "alpha", Some("idle")),
row("2-b", "bravo", Some("working")),
];
let json = render_plan_json(&build_plan(&rows), true).expect("json");
let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
assert_eq!(v["dry_run"], true);
assert_eq!(v["actionable"], 1);
assert_eq!(v["total"], 2);
assert_eq!(v["sessions"][0]["action"], "stop");
assert_eq!(v["sessions"][0]["verdict"], "idle");
assert_eq!(v["sessions"][1]["action"], "skip");
assert_eq!(v["sessions"][1]["reason"], "working");
}
#[test]
fn fetch_verdicts_preserves_order() {
let shuffled = vec![
(2, row("c", "charlie", Some("done"))),
(0, row("a", "alpha", Some("idle"))),
(1, row("b", "bravo", Some("working"))),
];
let ordered = reorder_by_index(shuffled);
let ids: Vec<&str> = ordered.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, ["a", "b", "c"]);
}
#[test]
fn render_unavailable_json_shape() {
let json = render_unavailable_json(true).expect("json");
let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
assert_eq!(v["dry_run"], true);
assert_eq!(v["actionable"], 0);
assert_eq!(v["total"], 0);
assert!(v["sessions"].is_array());
assert_eq!(v["sessions"].as_array().expect("array").len(), 0);
assert_eq!(v["sm_available"], false);
let avail = render_plan_json(&[], false).expect("json");
let av: serde_json::Value = serde_json::from_str(&avail).expect("parse");
assert_eq!(av["sm_available"], true);
}
#[test]
fn unavailable_exit_code_is_stable() {
assert_eq!(EXIT_SM_UNAVAILABLE, 75);
}
async fn spawn_test_daemon() -> (
String,
std::sync::Arc<trusty_mpm::daemon::state::DaemonState>,
) {
use trusty_mpm::daemon::{api, state::DaemonState};
let root = tempfile::tempdir().unwrap().keep();
let state = std::sync::Arc::new(DaemonState::with_root_isolated_managed(root).await);
let router = api::router(std::sync::Arc::clone(&state));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(axum::serve(listener, router).into_future());
(format!("http://{addr}"), state)
}
async fn seed_session(state: &trusty_mpm::daemon::state::DaemonState, name: &str) -> String {
use trusty_mpm::runtime::RuntimeKind;
use trusty_mpm::session_manager::ManagedSessionId;
let id = ManagedSessionId::new();
let ws = std::env::temp_dir().join(format!("{id}-{name}-ws"));
state
.session_manager()
.await
.create_with_id(
id,
format!("regression: #2521 best-effort sweep ({name})"),
Some(ws.clone()),
None,
Some(ws),
Some("https://example.com/r.git".to_string()),
Some("main".to_string()),
RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session");
id.to_string()
}
#[tokio::test]
async fn execute_plan_is_best_effort_and_reports_accurately() {
let (url, state) = spawn_test_daemon().await;
let client = reqwest::Client::new();
let alpha_id = seed_session(&state, "alpha").await;
let charlie_id = seed_session(&state, "charlie").await;
let missing_id = "nonexistent-middle-session".to_string();
let plan = vec![
PlannedAction {
id: alpha_id.clone(),
name: "alpha".to_string(),
verdict: "idle".to_string(),
action: PruneAction::Stop,
},
PlannedAction {
id: missing_id.clone(),
name: "bravo".to_string(),
verdict: "idle".to_string(),
action: PruneAction::Stop,
},
PlannedAction {
id: charlie_id.clone(),
name: "charlie".to_string(),
verdict: "idle".to_string(),
action: PruneAction::Stop,
},
];
let executed = execute_plan(&client, &url, &plan).await;
assert_eq!(
executed.len(),
3,
"the middle failure must not truncate the sweep"
);
assert_eq!(executed[0].id, alpha_id);
assert_eq!(executed[0].outcome, ExecOutcome::Succeeded);
assert_eq!(executed[1].id, missing_id);
assert!(
matches!(executed[1].outcome, ExecOutcome::Failed(_)),
"the never-seeded middle session must be reported as a failure, not silently dropped: {:?}",
executed[1].outcome
);
assert_eq!(executed[2].id, charlie_id);
assert_eq!(
executed[2].outcome,
ExecOutcome::Succeeded,
"the row AFTER the failing one must still have been attempted and succeeded"
);
assert_eq!(summarize_execution(&executed), (2, 1, 0));
let text = render_execution_summary_text(&executed);
assert!(text.contains("FAILED"));
assert!(text.contains("bravo"));
assert!(text.contains("2 succeeded, 1 failed, 0 skipped of 3 session(s)"));
let json = render_execution_summary_json(&executed).expect("json");
let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
assert_eq!(v["succeeded"], 2);
assert_eq!(v["failed"], 1);
assert_eq!(v["skipped"], 0);
assert_eq!(v["total"], 3);
assert_eq!(v["sessions"][0]["outcome"], "succeeded");
assert_eq!(v["sessions"][1]["outcome"], "failed");
assert!(
v["sessions"][1]["error"]
.as_str()
.expect("error string")
.contains(&missing_id)
);
assert_eq!(v["sessions"][2]["outcome"], "succeeded");
}
#[test]
fn summarize_execution_counts_all_three_outcomes() {
let rows = vec![
ExecutedRow {
id: "1".to_string(),
name: "a".to_string(),
outcome: ExecOutcome::Succeeded,
},
ExecutedRow {
id: "2".to_string(),
name: "b".to_string(),
outcome: ExecOutcome::Failed("boom".to_string()),
},
ExecutedRow {
id: "3".to_string(),
name: "c".to_string(),
outcome: ExecOutcome::Skipped,
},
ExecutedRow {
id: "4".to_string(),
name: "d".to_string(),
outcome: ExecOutcome::Succeeded,
},
];
assert_eq!(summarize_execution(&rows), (2, 1, 1));
}
#[test]
fn render_execution_summary_text_reports_failures() {
let rows = vec![
ExecutedRow {
id: "aaaa-1111".to_string(),
name: "alpha".to_string(),
outcome: ExecOutcome::Succeeded,
},
ExecutedRow {
id: "bbbb-2222".to_string(),
name: "bravo".to_string(),
outcome: ExecOutcome::Failed("session not found".to_string()),
},
];
let out = render_execution_summary_text(&rows);
assert!(out.contains("FAILED"));
assert!(out.contains("bravo"));
assert!(out.contains("bbbb"));
assert!(out.contains("session not found"));
assert!(out.contains("1 succeeded, 1 failed, 0 skipped of 2 session(s)"));
}
#[test]
fn render_execution_summary_json_reports_failures() {
let rows = vec![
ExecutedRow {
id: "1".to_string(),
name: "alpha".to_string(),
outcome: ExecOutcome::Succeeded,
},
ExecutedRow {
id: "2".to_string(),
name: "bravo".to_string(),
outcome: ExecOutcome::Failed("boom".to_string()),
},
];
let json = render_execution_summary_json(&rows).expect("json");
let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
assert_eq!(v["succeeded"], 1);
assert_eq!(v["failed"], 1);
assert_eq!(v["skipped"], 0);
assert_eq!(v["total"], 2);
assert_eq!(v["sessions"][0]["outcome"], "succeeded");
assert_eq!(v["sessions"][0]["error"], "");
assert_eq!(v["sessions"][1]["outcome"], "failed");
assert_eq!(v["sessions"][1]["error"], "boom");
}