//! Integration tests for the daemon's HTTP + WS API (NAP-243). mod common; use std::collections::HashMap; use std::net::{Ipv4Addr, SocketAddr}; use std::time::Duration; use common::DaemonFixture; use futures_util::{SinkExt, StreamExt}; use maestro_daemon::local_api; use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec}; use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; use uuid::Uuid; type WsClient = WebSocketStream>; struct Harness { addr: SocketAddr, manager: maestro_daemon::SessionManager, _shutdown: oneshot::Sender<()>, _fx: DaemonFixture, } impl Harness { /// Store over the same dir the manager records into. fn fx_retained(&self) -> maestro_daemon::retained_store::RetainedStore { self._fx.retained_store() } } async fn spawn(hostname: &str) -> Harness { spawn_with( hostname, std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), ) .await } async fn spawn_with( hostname: &str, projects: std::sync::Arc, ) -> Harness { let fx = DaemonFixture::new(); let manager = fx.manager.clone(); // Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises // a known set (#26). let presets = maestro_config::SpawnSection::default().resolve().unwrap(); let router = local_api::router(local_api::state_for( manager.clone(), hostname, "/home/test", presets, projects, std::sync::Arc::new(fx.retained_store()), )); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await .unwrap(); let addr = listener.local_addr().unwrap(); let (tx, rx) = oneshot::channel::<()>(); tokio::spawn(async move { let _ = axum::serve(listener, router) .with_graceful_shutdown(async move { let _ = rx.await; }) .await; }); Harness { addr, manager, _shutdown: tx, _fx: fx, } } fn url(addr: SocketAddr, path: &str) -> String { format!("http://{addr}{path}") } fn ws_url(addr: SocketAddr, path: &str) -> String { format!("ws://{addr}{path}") } #[tokio::test] async fn info_returns_daemon_identity() { let h = spawn("naps-laptop").await; let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info")) .await .unwrap() .json() .await .unwrap(); assert_eq!(resp["hostname"], "naps-laptop"); assert_eq!(resp["home_dir"], "/home/test"); assert!(resp["version"].is_string()); } #[tokio::test] async fn list_sessions_starts_empty_and_reflects_creates() { let h = spawn("naps-laptop").await; let empty: Vec = reqwest::get(url(h.addr, "/api/sessions")) .await .unwrap() .json() .await .unwrap(); assert!(empty.is_empty()); let req = CreateSessionRequest { cwd: "/tmp".into(), command: "sleep 60".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, }; let created: Session = reqwest::Client::new() .post(url(h.addr, "/api/sessions")) .json(&req) .send() .await .unwrap() .json() .await .unwrap(); let after: Vec = reqwest::get(url(h.addr, "/api/sessions")) .await .unwrap() .json() .await .unwrap(); assert_eq!(after.len(), 1); assert_eq!(after[0].id, created.id); } #[tokio::test] async fn info_advertises_spawn_presets() { // #26: the daemon's presets ride the info payload so the spawn form needs // no extra round-trip. Built-ins + the implicit custom. let h = spawn("naps-laptop").await; let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info")) .await .unwrap() .json() .await .unwrap(); let ids: Vec<&str> = resp["presets"] .as_array() .unwrap() .iter() .map(|p| p["id"].as_str().unwrap()) .collect(); assert_eq!(ids, ["claude", "opencode", "zsh", "custom"]); } #[tokio::test] async fn recent_dirs_returns_empty_without_history() { // #26: reads this daemon's spawn history. The test fixture wires no // history, so the list is empty but the endpoint is well-formed. let h = spawn("naps-laptop").await; let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/recent-dirs")) .await .unwrap() .json() .await .unwrap(); assert_eq!(resp["dirs"].as_array().unwrap().len(), 0); assert_eq!(resp["truncated"], false); } /// #54: `GET /api/projects` lists every git repo under the scan roots, with /// real git checkouts — not mocks. #[tokio::test] async fn projects_lists_repos_under_the_scan_roots() { let tmp = tempfile::tempdir().unwrap(); for name in ["alpha", "beta"] { let repo = tmp.path().join(name); std::fs::create_dir_all(&repo).unwrap(); for args in [ vec!["init", "-q"], vec![ "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "init", "--allow-empty", ], ] { let out = std::process::Command::new("git") .arg("-C") .arg(&repo) .args(&args) .output() .unwrap(); assert!(out.status.success()); } } let scanner = std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::new( vec![tmp.path().to_path_buf()], 3, tmp.path().to_path_buf(), )); let h = spawn_with("naps-laptop", scanner).await; let projects: Vec = reqwest::get(url(h.addr, "/api/projects")) .await .unwrap() .json() .await .unwrap(); let mut names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect(); names.sort(); assert_eq!(names, ["alpha", "beta"]); for p in &projects { assert!(!p.is_worktree); assert_eq!(p.base_repo, None); assert!(p.last_activity.is_some(), "fresh commit ⇒ HEAD mtime known"); } } #[tokio::test] async fn projects_returns_empty_for_a_disabled_scanner() { let h = spawn("naps-laptop").await; let projects: Vec = reqwest::get(url(h.addr, "/api/projects")) .await .unwrap() .json() .await .unwrap(); assert!(projects.is_empty()); } /// #53: every returned handle splits into a known adjective and noun. fn assert_valid_names(batch: &maestro_protocol::NameBatch) { for name in &batch.names { let (adj, noun) = name.split_once('-').unwrap_or_else(|| { panic!("handle {name:?} is not adjective-noun"); }); assert!( maestro_names::ADJECTIVES.contains(&adj), "{adj} not in ADJECTIVES" ); assert!(maestro_names::NOUNS.contains(&noun), "{noun} not in NOUNS"); } } #[tokio::test] async fn names_defaults_to_one_handle() { let h = spawn("naps-laptop").await; let batch: maestro_protocol::NameBatch = reqwest::get(url(h.addr, "/api/names")) .await .unwrap() .json() .await .unwrap(); assert_eq!(batch.names.len(), 1); assert_valid_names(&batch); } #[tokio::test] async fn names_honors_count_and_caps_it() { use maestro_names::MAX_BATCH; let h = spawn("naps-laptop").await; let batch: maestro_protocol::NameBatch = reqwest::get(url(h.addr, "/api/names?count=5")) .await .unwrap() .json() .await .unwrap(); assert_eq!(batch.names.len(), 5); assert_valid_names(&batch); let batch: maestro_protocol::NameBatch = reqwest::get(url(h.addr, &format!("/api/names?count={}", MAX_BATCH * 10))) .await .unwrap() .json() .await .unwrap(); assert_eq!(batch.names.len(), MAX_BATCH); } #[tokio::test] async fn gitroot_probes_path() { // #26: resolves the query against the daemon home dir and walks up for a // `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null. let h = spawn("naps-laptop").await; let tmp = tempfile::tempdir().unwrap(); let resp: serde_json::Value = reqwest::get(url( h.addr, &format!( "/api/gitroot?path={}", urlencoding::encode(&tmp.path().to_string_lossy()) ), )) .await .unwrap() .json() .await .unwrap(); assert!(resp.get("root").is_none() || resp["root"].is_null()); } #[tokio::test] async fn delete_unknown_session_returns_404() { let h = spawn("naps-laptop").await; let resp = reqwest::Client::new() .delete(url(h.addr, &format!("/api/sessions/{}", Uuid::new_v4()))) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND); } #[tokio::test] async fn delete_owned_session_kills_it() { let h = spawn("naps-laptop").await; let session = h .manager .create(CreateSessionRequest { cwd: "/tmp".into(), command: "sleep 60".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, }) .await .unwrap(); let resp = reqwest::Client::new() .delete(url(h.addr, &format!("/api/sessions/{}", session.id))) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT); assert!(!h.manager.has(session.id).await); } /// Spawn a session through the manager and return its id. async fn spawn_via_manager(h: &Harness) -> Uuid { h.manager .create(CreateSessionRequest { cwd: "/tmp".into(), command: "sleep 60".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, }) .await .unwrap() .id } /// The agent activity endpoint on the wire (#105): a report lands in the /// session snapshot with its detail. #[tokio::test] async fn post_activity_reports_error_with_detail() { let h = spawn("naps-laptop").await; let id = spawn_via_manager(&h).await; let resp = reqwest::Client::new() .post(url(h.addr, &format!("/api/sessions/{id}/activity"))) .json(&serde_json::json!({ "state": "Error", "detail": "cargo build failed" })) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT); let sessions: Vec = reqwest::get(url(h.addr, "/api/sessions")) .await .unwrap() .json() .await .unwrap(); let s = sessions.iter().find(|s| s.id == id).unwrap(); assert_eq!(s.activity, maestro_protocol::ActivityState::Error); assert_eq!(s.activity_detail.as_deref(), Some("cargo build failed")); } #[tokio::test] async fn post_activity_to_unknown_session_returns_404() { let h = spawn("naps-laptop").await; let resp = reqwest::Client::new() .post(url( h.addr, &format!("/api/sessions/{}/activity", Uuid::new_v4()), )) .json(&serde_json::json!({ "state": "Idle" })) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND); } /// Unknown state values are rejected rather than coerced (#105). #[tokio::test] async fn post_activity_rejects_an_unknown_state_value() { let h = spawn("naps-laptop").await; let id = spawn_via_manager(&h).await; let resp = reqwest::Client::new() .post(url(h.addr, &format!("/api/sessions/{id}/activity"))) .json(&serde_json::json!({ "state": "Teleported" })) .send() .await .unwrap(); assert!(resp.status().is_client_error(), "got {}", resp.status()); } #[tokio::test] async fn post_activity_caps_an_overlong_detail_server_side() { let h = spawn("naps-laptop").await; let id = spawn_via_manager(&h).await; let resp = reqwest::Client::new() .post(url(h.addr, &format!("/api/sessions/{id}/activity"))) .json(&serde_json::json!({ "state": "Error", "detail": "x".repeat(10_000) })) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT); let sessions: Vec = reqwest::get(url(h.addr, "/api/sessions")) .await .unwrap() .json() .await .unwrap(); let s = sessions.iter().find(|s| s.id == id).unwrap(); assert_eq!( s.activity_detail.as_ref().map(|d| d.chars().count()), Some(maestro_daemon::MAX_REPORT_DETAIL_CHARS) ); } /// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409 /// with the structured breakdown, and the session keeps running. #[tokio::test] async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the_breakdown() { let h = spawn("naps-laptop").await; let repo = tempfile::TempDir::new().unwrap(); common::init_repo(repo.path()); let session = h .manager .create(CreateSessionRequest { cwd: repo.path().to_path_buf(), command: "sleep 60".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: Some(WorktreeSpec { base_repo: repo.path().to_path_buf(), branch: "feat/guarded".into(), create_branch: true, }), }) .await .unwrap(); std::fs::write( repo.path() .join("worktrees") .join("feat") .join("guarded") .join("README"), b"unsaved edits\n", ) .unwrap(); let resp = reqwest::Client::new() .delete(url(h.addr, &format!("/api/sessions/{}", session.id))) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::CONFLICT); let body: ApiError = resp.json().await.unwrap(); assert_eq!(body.error.code, "kill_guarded"); let breakdown = body.error.unsaved_work.expect("breakdown on the wire"); assert_eq!(breakdown.uncommitted_files, 1); assert!(h.manager.has(session.id).await, "the session keeps running"); } /// `?force=true` on the wire (#81): the refusal is overridden, the session /// ends, and the answer is 200 with the retained worktree named — path, /// branch, reason — while the directory survives on disk. #[tokio::test] async fn delete_with_force_ends_the_session_and_answers_what_was_retained() { let h = spawn("naps-laptop").await; let repo = tempfile::TempDir::new().unwrap(); common::init_repo(repo.path()); let wt_path = repo .path() .join("worktrees") .join("feat") .join("guarded") .to_path_buf(); let session = h .manager .create(CreateSessionRequest { cwd: repo.path().to_path_buf(), command: "sleep 60".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: Some(WorktreeSpec { base_repo: repo.path().to_path_buf(), branch: "feat/guarded".into(), create_branch: true, }), }) .await .unwrap(); std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); let resp = reqwest::Client::new() .delete(url(h.addr, &format!("/api/sessions/{}", session.id))) .query(&[("force", "true")]) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::OK); let body: maestro_protocol::KillOutcome = resp.json().await.unwrap(); let retained = body.retained.expect("force names what was kept"); assert_eq!(retained.path, wt_path); assert_eq!(retained.branch, "feat/guarded"); assert_eq!( retained.reason, maestro_protocol::RetainedReason::ForceKilled ); // The tree would have blocked; force must not have removed it. assert!(wt_path.exists(), "a forced kill never removes the worktree"); // And the store entry exists, so reclaim can find it (#80). let all = h.fx_retained().load_all().unwrap(); assert_eq!(all.len(), 1); assert_eq!(all[0].id, retained.id); } #[tokio::test] async fn attach_ws_streams_pty_bytes() { let h = spawn("naps-laptop").await; let session = h .manager .create(CreateSessionRequest { cwd: "/tmp".into(), command: "bash --noprofile --norc".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, }) .await .unwrap(); let ws_url = format!("ws://{}/api/attach/{}", h.addr, session.id); let (mut ws, _): (WsClient, _) = connect_async(ws_url).await.unwrap(); // Provoke output. The bash process should echo this back via the PTY. ws.send(WsMessage::Binary(b"echo hello-attach\n".to_vec())) .await .unwrap(); let saw_hello = tokio::time::timeout(Duration::from_secs(2), async { loop { match ws.next().await { Some(Ok(WsMessage::Binary(bytes))) => { if String::from_utf8_lossy(&bytes).contains("hello-attach") { return true; } } Some(Ok(_)) => continue, Some(Err(_)) | None => return false, } } }) .await .unwrap_or(false); assert!(saw_hello, "PTY did not echo through the attach WS"); } #[tokio::test] async fn attach_ws_for_unknown_session_returns_404() { let h = spawn("naps-laptop").await; let ws_url = format!("ws://{}/api/attach/{}", h.addr, Uuid::new_v4()); let err = connect_async(ws_url) .await .expect_err("expected handshake fail"); let msg = err.to_string(); assert!( msg.contains("404") || msg.to_lowercase().contains("not found"), "{msg}" ); } /// `WS /api/events` had no daemon-level test: the Snapshot-then-deltas contract /// and the subscribe-before-snapshot ordering were only described in a comment. #[tokio::test] async fn events_ws_sends_a_snapshot_then_streams_deltas() { let h = spawn("konishi").await; let cwd = tempfile::TempDir::new().unwrap(); let (mut ws, _) = connect_async(ws_url(h.addr, "/api/events")) .await .expect("events ws"); match next_event(&mut ws).await { maestro_protocol::CatalogEvent::Snapshot { sessions } => assert!(sessions.is_empty()), other => panic!("first frame must be a Snapshot, got {other:?}"), } let created = h .manager .create(CreateSessionRequest { cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, }) .await .expect("create"); match next_event(&mut ws).await { maestro_protocol::CatalogEvent::SessionAdded { session } => { assert_eq!(session.id, created.id); } other => panic!("expected SessionAdded, got {other:?}"), } h.manager.kill(created.id, false).await.expect("kill"); // Activity ticks may interleave; the removal is what we are waiting for. let deadline = tokio::time::Instant::now() + Duration::from_secs(5); loop { assert!( tokio::time::Instant::now() < deadline, "timed out waiting for SessionRemoved" ); if let maestro_protocol::CatalogEvent::SessionRemoved { id } = next_event(&mut ws).await { assert_eq!(id, created.id); break; } } } /// Each subscriber has its own lag domain now that the publisher task is gone: /// two clients connected at different times both get their own Snapshot. #[tokio::test] async fn every_events_subscriber_gets_its_own_snapshot() { let h = spawn("konishi").await; let cwd = tempfile::TempDir::new().unwrap(); let (mut first, _) = connect_async(ws_url(h.addr, "/api/events")).await.unwrap(); let _ = next_event(&mut first).await; h.manager .create(CreateSessionRequest { cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: None, }) .await .expect("create"); let (mut second, _) = connect_async(ws_url(h.addr, "/api/events")).await.unwrap(); match next_event(&mut second).await { maestro_protocol::CatalogEvent::Snapshot { sessions } => { assert_eq!( sessions.len(), 1, "a late subscriber must see current state" ); } other => panic!("expected Snapshot, got {other:?}"), } } async fn next_event(ws: &mut WsClient) -> maestro_protocol::CatalogEvent { loop { match tokio::time::timeout(Duration::from_secs(5), ws.next()) .await .expect("events ws timed out") { Some(Ok(WsMessage::Text(t))) => { return serde_json::from_str(&t).expect("decode catalog event") } Some(Ok(_)) => continue, other => panic!("unexpected events frame: {other:?}"), } } } // --------------------------------------------------------------------------- // GET /api/worktrees + DELETE /api/worktrees/:id (#80) // --------------------------------------------------------------------------- /// `git -C dir args`, asserting success — the worktree endpoints are tested /// against real git, like everything else in this crate. fn git(dir: &std::path::Path, args: &[&str]) { let out = std::process::Command::new("git") .arg("-C") .arg(dir) .args(args) .output() .expect("git spawn"); assert!( out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr) ); } struct RetainedFixture { repo: tempfile::TempDir, wt: std::path::PathBuf, } /// A real repo with a linked worktree for branch `feat/login` whose tree holds /// an uncommitted tracked edit and an unpushed commit. fn retained_fixture() -> RetainedFixture { let repo = tempfile::tempdir().unwrap(); common::init_repo(repo.path()); // A pushed main, so "unpushed" means commits made on the feature branch, // not the shared history. let remote = repo.path().join("remote.git"); std::fs::create_dir_all(&remote).unwrap(); let remote_s = remote.to_string_lossy().into_owned(); git(&remote, &["init", "-q", "--bare"]); git(repo.path(), &["remote", "add", "origin", &remote_s]); git(repo.path(), &["push", "-q", "-u", "origin", "main"]); git( repo.path(), &[ "worktree", "add", "-q", "-b", "feat/login", "worktrees/feat/login", ], ); let wt = repo.path().join("worktrees").join("feat").join("login"); RetainedFixture { repo, wt } } impl RetainedFixture { fn commit_in_wt(&self, msg: &str) { std::fs::write(self.wt.join("README"), msg.as_bytes()).unwrap(); git(&self.wt, &["add", "-A"]); git(&self.wt, &["commit", "-qm", msg]); } fn branch_sha(&self, branch: &str) -> String { let out = std::process::Command::new("git") .arg("-C") .arg(self.repo.path()) .args(["rev-parse", "--verify", &format!("refs/heads/{branch}")]) .output() .unwrap(); assert!(out.status.success(), "branch {branch} must exist"); String::from_utf8_lossy(&out.stdout).trim().to_string() } } #[tokio::test] async fn worktrees_lists_retained_entries_with_recomputed_breakdown() { let h = spawn("naps-laptop").await; let fx = retained_fixture(); // Uncommitted tracked edit + an untracked file; the branch has no commits // beyond main yet. std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap(); std::fs::write(fx.wt.join("scratch.txt"), b"loose\n").unwrap(); let store = h._fx.retained_store(); let entry = store .retain( &fx.wt, fx.repo.path(), "feat/login", maestro_daemon::retained_store::RetainedReason::ForceKilled, ) .unwrap(); let listed: Vec = reqwest::get(url(h.addr, "/api/worktrees")) .await .unwrap() .json() .await .unwrap(); assert_eq!(listed.len(), 1); let w = &listed[0]; assert_eq!(w.id, entry.id); assert_eq!(w.path, fx.wt); assert_eq!(w.base_repo, fx.repo.path()); assert_eq!(w.branch, "feat/login"); assert_eq!(w.reason, maestro_protocol::RetainedReason::ForceKilled); assert_eq!(w.in_the_way.uncommitted_files, 1); assert_eq!(w.in_the_way.untracked_files, 1); assert_eq!(w.in_the_way.unpushed_commits, 0); // The operator saves the work themselves. The stored entry is untouched — // the next read must report the tree as it is *now*, not as it was when // it was retained. fx.commit_in_wt("saved"); let listed: Vec = reqwest::get(url(h.addr, "/api/worktrees")) .await .unwrap() .json() .await .unwrap(); assert_eq!(listed.len(), 1, "same entry, still retained"); assert_eq!(listed[0].id, entry.id); assert_eq!(listed[0].in_the_way.uncommitted_files, 0); assert_eq!(listed[0].in_the_way.unpushed_commits, 1); } #[tokio::test] async fn reclaim_removes_directory_and_entry_but_never_the_branch() { let h = spawn("naps-laptop").await; let fx = retained_fixture(); fx.commit_in_wt("precious"); let sha = fx.branch_sha("feat/login"); std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap(); // guard would refuse let store = h._fx.retained_store(); let entry = store .retain( &fx.wt, fx.repo.path(), "feat/login", maestro_daemon::retained_store::RetainedReason::ExitedWithUnsavedWork, ) .unwrap(); let resp = reqwest::Client::new() .delete(url(h.addr, &format!("/api/worktrees/{}", entry.id))) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT); assert!(!fx.wt.exists(), "reclaim removes the directory"); let listed: Vec = reqwest::get(url(h.addr, "/api/worktrees")) .await .unwrap() .json() .await .unwrap(); assert!(listed.is_empty(), "reclaim drops the store entry"); assert_eq!( fx.branch_sha("feat/login"), sha, "the branch and its commits survive" ); } #[tokio::test] async fn reclaim_of_an_already_gone_directory_still_drops_the_entry() { let h = spawn("naps-laptop").await; let fx = retained_fixture(); let store = h._fx.retained_store(); let entry = store .retain( &fx.wt, fx.repo.path(), "feat/login", maestro_daemon::retained_store::RetainedReason::ForceKilled, ) .unwrap(); std::fs::remove_dir_all(&fx.wt).unwrap(); // operator rm -rf let resp = reqwest::Client::new() .delete(url(h.addr, &format!("/api/worktrees/{}", entry.id))) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT); assert!(store.load_all().unwrap().is_empty()); } #[tokio::test] async fn reclaim_unknown_id_returns_404() { let h = spawn("naps-laptop").await; let resp = reqwest::Client::new() .delete(url(h.addr, &format!("/api/worktrees/{}", Uuid::new_v4()))) .send() .await .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND); } #[tokio::test] async fn worktrees_prunes_entries_whose_directory_vanished_by_hand() { let h = spawn("naps-laptop").await; let fx = retained_fixture(); let store = h._fx.retained_store(); store .retain( &fx.wt, fx.repo.path(), "feat/login", maestro_daemon::retained_store::RetainedReason::ForceKilled, ) .unwrap(); std::fs::remove_dir_all(&fx.wt).unwrap(); let listed: Vec = reqwest::get(url(h.addr, "/api/worktrees")) .await .unwrap() .json() .await .unwrap(); assert!(listed.is_empty(), "a hand-removed directory is not listed"); assert!( store.load_all().unwrap().is_empty(), "and its entry is gone" ); }