use std::net::SocketAddr;
use crate::commands::picker_delete::DeleteReport;
#[test]
fn delete_needs_stop_first_only_for_errored() {
use crate::commands::picker_delete::delete_needs_stop_first;
assert!(delete_needs_stop_first("errored"));
for state in [
"stopped",
"active",
"provisioning",
"decommissioned",
"deleted",
] {
assert!(
!delete_needs_stop_first(state),
"{state} must not take the stop-first route"
);
}
}
#[test]
fn classify_stop_first_maps_each_status() {
use crate::commands::picker_delete::{StopFirstNext, classify_stop_first};
assert_eq!(
classify_stop_first(reqwest::StatusCode::OK),
StopFirstNext::Stopped
);
assert_eq!(
classify_stop_first(reqwest::StatusCode::ACCEPTED),
StopFirstNext::Stopped,
"any 2xx means the daemon accepted the stop"
);
assert_eq!(
classify_stop_first(reqwest::StatusCode::NOT_FOUND),
StopFirstNext::NothingToStop,
"404 is the daemon's explicit 'no live session here'"
);
for status in [
reqwest::StatusCode::CONFLICT,
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
reqwest::StatusCode::SERVICE_UNAVAILABLE,
reqwest::StatusCode::BAD_REQUEST,
] {
assert_eq!(
classify_stop_first(status),
StopFirstNext::Failed,
"{status} must NOT be downgraded into a delete"
);
}
}
#[derive(Clone)]
struct StopStub {
stop_status: axum::http::StatusCode,
stops: std::sync::Arc<std::sync::atomic::AtomicUsize>,
deletes: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
impl StopStub {
fn new(stop_status: axum::http::StatusCode) -> Self {
Self {
stop_status,
stops: Default::default(),
deletes: Default::default(),
}
}
fn router(&self) -> axum::Router {
use axum::{Router, extract::State, routing::post};
let stub = self.clone();
Router::new()
.route(
"/api/v1/sessions/managed/{id}/runtime-stop",
post(|State(s): State<StopStub>| async move {
s.stops.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
(s.stop_status, "stub stop body")
}),
)
.route(
"/api/v1/sessions/managed/{id}/delete",
post(|State(s): State<StopStub>| async move {
s.deletes.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
axum::Json(serde_json::json!({
"name": "tm-stub-01",
"state": "errored",
"deleted": true,
}))
}),
)
.with_state(stub)
}
fn counts(&self) -> (usize, usize) {
use std::sync::atomic::Ordering::SeqCst;
(self.stops.load(SeqCst), self.deletes.load(SeqCst))
}
}
async fn serve_stub(router: axum::Router) -> (String, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback port");
let addr: SocketAddr = listener.local_addr().expect("resolve bound addr");
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
(format!("http://{addr}"), handle)
}
#[tokio::test]
async fn stop_then_delete_deletes_after_a_successful_stop() {
use crate::commands::picker_delete::stop_then_delete;
let stub = StopStub::new(axum::http::StatusCode::OK);
let (url, handle) = serve_stub(stub.router()).await;
let report = stop_then_delete(&reqwest::Client::new(), &url, "sid-1", false)
.await
.expect("routing call must not error");
assert!(
matches!(report, DeleteReport::Deleted { local: false, .. }),
"expected a managed delete, got {report:?}"
);
assert_eq!(stub.counts(), (1, 1), "one stop, then one delete");
handle.abort();
}
#[tokio::test]
async fn stop_then_delete_treats_not_found_as_nothing_to_stop() {
use crate::commands::picker_delete::stop_then_delete;
let stub = StopStub::new(axum::http::StatusCode::NOT_FOUND);
let (url, handle) = serve_stub(stub.router()).await;
let report = stop_then_delete(&reqwest::Client::new(), &url, "sid-2", false)
.await
.expect("routing call must not error");
assert!(
matches!(report, DeleteReport::Deleted { .. }),
"a 404 stop must not block the delete, got {report:?}"
);
assert_eq!(stub.counts(), (1, 1));
handle.abort();
}
#[tokio::test]
async fn stop_then_delete_never_deletes_after_a_failed_stop() {
use crate::commands::picker_delete::stop_then_delete;
for status in [
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::http::StatusCode::CONFLICT,
] {
let stub = StopStub::new(status);
let (url, handle) = serve_stub(stub.router()).await;
let report = stop_then_delete(&reqwest::Client::new(), &url, "sid-3", false)
.await
.expect("routing call must not error");
match report {
DeleteReport::StopFailed(msg) => {
assert!(
msg.contains(status.as_str()),
"must carry the status: {msg}"
);
assert!(msg.contains("stub stop body"), "must carry the body: {msg}");
}
other => panic!("a {status} stop must report StopFailed, got {other:?}"),
}
assert_eq!(
stub.counts(),
(1, 0),
"a rejected stop must issue NO delete request ({status})"
);
handle.abort();
}
}
#[derive(Clone)]
struct LiveErroredStub {
stop_status: axum::http::StatusCode,
runtime_down: std::sync::Arc<std::sync::atomic::AtomicBool>,
stops: std::sync::Arc<std::sync::atomic::AtomicUsize>,
deletes: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
impl LiveErroredStub {
fn new(stop_status: axum::http::StatusCode) -> Self {
Self {
stop_status,
runtime_down: Default::default(),
stops: Default::default(),
deletes: Default::default(),
}
}
fn router(&self) -> axum::Router {
use std::sync::atomic::Ordering::SeqCst;
use axum::{Router, extract::State, response::IntoResponse, routing::post};
let stub = self.clone();
Router::new()
.route(
"/api/v1/sessions/managed/{id}/runtime-stop",
post(|State(s): State<LiveErroredStub>| async move {
s.stops.fetch_add(1, SeqCst);
if s.stop_status.is_success()
|| s.stop_status == axum::http::StatusCode::NOT_FOUND
{
s.runtime_down.store(true, SeqCst);
}
(s.stop_status, "stub stop body")
}),
)
.route(
"/api/v1/sessions/managed/{id}/delete",
post(|State(s): State<LiveErroredStub>| async move {
s.deletes.fetch_add(1, SeqCst);
if !s.runtime_down.load(SeqCst) {
return (
axum::http::StatusCode::CONFLICT,
"session is errored — stop it first with `tm session stop`",
)
.into_response();
}
axum::Json(serde_json::json!({
"name": "tm-quiet-falcon",
"state": "errored",
"deleted": true,
}))
.into_response()
}),
)
.with_state(stub)
}
fn counts(&self) -> (usize, usize) {
use std::sync::atomic::Ordering::SeqCst;
(self.stops.load(SeqCst), self.deletes.load(SeqCst))
}
}
fn row(state: &str) -> trusty_mpm::client::ManagedSessionSummary {
trusty_mpm::client::ManagedSessionSummary {
id: "sid-picker".to_string(),
name: "tm-quiet-falcon".to_string(),
state: state.to_string(),
persisted_state: None,
workspace_path: None,
repo_url: None,
branch: None,
created_at: None,
last_activity_at: None,
pending_decision: None,
proposed_default: None,
source_id: None,
task: None,
cwd: None,
claude_session_id: None,
deliverable_id: None,
pane_id: None,
injection_status: None,
unresumable: false,
stale_assets: false,
stale_assets_unchecked: false,
attached: false,
slot: 1,
deleted: false,
auto_resume_parked: None,
}
}
#[tokio::test]
async fn picker_delete_stops_an_errored_session_before_deleting_it() {
use crate::commands::picker_delete::delete_confirmed;
let stub = LiveErroredStub::new(axum::http::StatusCode::OK);
let (url, handle) = serve_stub(stub.router()).await;
let deleted = delete_confirmed(&reqwest::Client::new(), &url, &row("errored"))
.await
.expect("the delete driver must not error");
assert!(deleted, "the errored row must end up deleted, not refused");
assert_eq!(stub.counts(), (1, 1), "one stop, then one delete");
handle.abort();
}
#[tokio::test]
async fn picker_delete_treats_a_not_found_stop_as_nothing_to_stop() {
use crate::commands::picker_delete::delete_confirmed;
let stub = LiveErroredStub::new(axum::http::StatusCode::NOT_FOUND);
let (url, handle) = serve_stub(stub.router()).await;
let deleted = delete_confirmed(&reqwest::Client::new(), &url, &row("errored"))
.await
.expect("the delete driver must not error");
assert!(deleted, "a 404 stop must not block the delete");
assert_eq!(stub.counts(), (1, 1));
handle.abort();
}
#[tokio::test]
async fn picker_delete_never_deletes_after_a_failed_stop() {
use crate::commands::picker_delete::delete_confirmed;
for status in [
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::http::StatusCode::CONFLICT,
] {
let stub = LiveErroredStub::new(status);
let (url, handle) = serve_stub(stub.router()).await;
let deleted = delete_confirmed(&reqwest::Client::new(), &url, &row("errored"))
.await
.expect("the delete driver must not error");
assert!(!deleted, "a {status} stop must not report a deletion");
assert_eq!(
stub.counts(),
(1, 0),
"a rejected stop must issue NO delete request ({status})"
);
handle.abort();
}
}
#[tokio::test]
async fn picker_delete_issues_no_stop_for_a_non_errored_row() {
use crate::commands::picker_delete::delete_confirmed;
let stub = StopStub::new(axum::http::StatusCode::OK);
let (url, handle) = serve_stub(stub.router()).await;
let deleted = delete_confirmed(&reqwest::Client::new(), &url, &row("stopped"))
.await
.expect("the delete driver must not error");
assert!(deleted);
assert_eq!(
stub.counts(),
(0, 1),
"a stopped row has no runtime to stop — one delete, no stop"
);
handle.abort();
}
#[test]
fn delete_route_flags_match_the_two_guards() {
use crate::commands::picker_delete::{
delete_needs_force, delete_needs_stop_first, delete_route_flags,
};
for state in [
"errored",
"stopped",
"active",
"provisioning",
"decommissioned",
"deleted",
] {
assert_eq!(
delete_route_flags(state),
(delete_needs_force(state), delete_needs_stop_first(state)),
"{state}"
);
}
}
#[test]
fn both_delete_surfaces_ask_the_same_errored_question() {
use crate::commands::picker_delete::errored_confirm_ask;
assert_eq!(
errored_confirm_ask("tm-quiet-falcon"),
"'tm-quiet-falcon' is errored. Stop it and delete it? Type y, then Enter."
);
}
#[derive(Clone)]
struct VerbStub {
state: Option<String>,
stop_status: axum::http::StatusCode,
live: std::sync::Arc<std::sync::atomic::AtomicBool>,
legs: std::sync::Arc<std::sync::Mutex<Vec<&'static str>>>,
}
impl VerbStub {
fn new(state: Option<&str>, stop_status: axum::http::StatusCode, live: bool) -> Self {
Self {
state: state.map(str::to_string),
stop_status,
live: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(live)),
legs: Default::default(),
}
}
fn router(&self) -> axum::Router {
use std::sync::atomic::Ordering::SeqCst;
use axum::{
Router,
extract::State,
response::IntoResponse,
routing::{get, post},
};
let stub = self.clone();
Router::new()
.route(
"/api/v1/sessions/managed/{id}",
get(|State(s): State<VerbStub>| async move {
match s.state {
Some(state) => axum::Json(serde_json::json!({
"id": "sid-verb",
"name": "tm-quiet-falcon",
"state": state,
}))
.into_response(),
None => (axum::http::StatusCode::NOT_FOUND, "no such managed session")
.into_response(),
}
}),
)
.route(
"/api/v1/sessions/managed/{id}/runtime-stop",
post(|State(s): State<VerbStub>| async move {
s.legs.lock().expect("legs lock").push("stop");
if s.stop_status.is_success()
|| s.stop_status == axum::http::StatusCode::NOT_FOUND
{
s.live.store(false, SeqCst);
}
(s.stop_status, "stub stop body")
}),
)
.route(
"/api/v1/sessions/managed/{id}/delete",
post(|State(s): State<VerbStub>| async move {
s.legs.lock().expect("legs lock").push("delete");
if s.live.load(SeqCst) {
return (
axum::http::StatusCode::CONFLICT,
"session 'tm-quiet-falcon' is errored — stop it first with \
`tm session stop`",
)
.into_response();
}
axum::Json(serde_json::json!({
"name": "tm-quiet-falcon",
"state": "errored",
"deleted": true,
}))
.into_response()
}),
)
.with_state(stub)
}
fn legs(&self) -> Vec<&'static str> {
self.legs.lock().expect("legs lock").clone()
}
}
#[tokio::test]
async fn verb_delete_stops_an_errored_session_before_deleting_it() {
let stub = VerbStub::new(Some("errored"), axum::http::StatusCode::OK, true);
let (url, handle) = serve_stub(stub.router()).await;
crate::commands::delete::session_delete(
&reqwest::Client::new(),
&url,
"sid-verb".to_string(),
false,
)
.await
.expect("an errored session must delete, not refuse");
assert_eq!(
stub.legs(),
vec!["stop", "delete"],
"the stop must arrive before the delete"
);
handle.abort();
}
#[tokio::test]
async fn verb_delete_never_deletes_after_a_failed_stop() {
for status in [
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::http::StatusCode::CONFLICT,
] {
let stub = VerbStub::new(Some("errored"), status, true);
let (url, handle) = serve_stub(stub.router()).await;
let err = crate::commands::delete::session_delete(
&reqwest::Client::new(),
&url,
"sid-verb".to_string(),
false,
)
.await
.expect_err("a rejected stop must not report success");
assert!(
err.to_string().contains("stop failed"),
"must name the failed stop: {err}"
);
assert_eq!(
stub.legs(),
vec!["stop"],
"a rejected stop must issue NO delete request ({status})"
);
handle.abort();
}
}
#[tokio::test]
async fn verb_delete_issues_no_stop_when_the_id_is_not_a_managed_record() {
let stub = VerbStub::new(None, axum::http::StatusCode::OK, false);
let (url, handle) = serve_stub(stub.router()).await;
crate::commands::delete::session_delete(
&reqwest::Client::new(),
&url,
"sid-verb".to_string(),
false,
)
.await
.expect("an unknown-state delete must route exactly as before");
assert_eq!(
stub.legs(),
vec!["delete"],
"an unclassifiable id must not pick up a stop leg"
);
handle.abort();
}
#[tokio::test]
async fn picker_and_verb_route_each_state_identically() {
use crate::commands::picker_delete::delete_confirmed;
for state in [
"provisioning",
"active",
"stopped",
"errored",
"decommissioned",
"deleted",
] {
let picker_stub = VerbStub::new(Some(state), axum::http::StatusCode::OK, false);
let (picker_url, picker_handle) = serve_stub(picker_stub.router()).await;
delete_confirmed(&reqwest::Client::new(), &picker_url, &row(state))
.await
.expect("the picker's delete driver must not error");
picker_handle.abort();
let verb_stub = VerbStub::new(Some(state), axum::http::StatusCode::OK, false);
let (verb_url, verb_handle) = serve_stub(verb_stub.router()).await;
let force = crate::commands::picker_delete::delete_needs_force(state);
crate::commands::delete::session_delete(
&reqwest::Client::new(),
&verb_url,
"sid-verb".to_string(),
force,
)
.await
.expect("the verb must not error");
verb_handle.abort();
assert_eq!(
picker_stub.legs(),
verb_stub.legs(),
"the picker and `tm session delete` must route a {state} session the same way"
);
}
}