//! LS2 integration tests for [`FsLiveStateStore`] against a **real** temp //! directory (programme live-state). //! //! These lock the durable behaviour an in-memory double cannot prove: //! - round-trip `upsert`/`load` survives a fresh instance ("restart"); //! - a missing file => empty `LiveState` (never an error); //! - the write is atomic (no `.tmp` residue left behind); //! - `upsert` is keyed last-writer-wins, persisted (no duplicate per agent); //! - `prune` (TTL + max_n) is persisted. //! //! Convention: a hand-rolled [`TempDir`] over the OS temp dir (calqué sur //! `conversation_log.rs`) — absolute path for `ProjectPath::new`, cleaned on drop. use std::path::PathBuf; use domain::live_state::{LiveEntry, WorkStatus}; use domain::ports::LiveStateStore; use domain::project::ProjectPath; use domain::AgentId; use infrastructure::FsLiveStateStore; use uuid::Uuid; struct TempDir(PathBuf); impl TempDir { fn new() -> Self { let p = std::env::temp_dir().join(format!("idea-ls2-livestate-{}", Uuid::new_v4())); std::fs::create_dir_all(&p).unwrap(); Self(p) } fn project_path(&self) -> ProjectPath { ProjectPath::new(self.0.to_string_lossy().into_owned()).unwrap() } fn file_path(&self) -> PathBuf { self.0.join(".ideai").join("live-state.json") } fn tmp_path(&self) -> PathBuf { self.0.join(".ideai").join("live-state.json.tmp") } } impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn aid(n: u128) -> AgentId { AgentId::from_uuid(Uuid::from_u128(n)) } fn entry(agent: u128, intent: &str, status: WorkStatus, updated_at_ms: u64) -> LiveEntry { LiveEntry::new(aid(agent), None, intent, status, None, None, updated_at_ms).unwrap() } #[tokio::test] async fn missing_file_loads_empty_without_error() { let tmp = TempDir::new(); let store = FsLiveStateStore::new(&tmp.project_path()); let state = store.load().await.unwrap(); assert!(state.entries.is_empty(), "no file ⇒ empty live-state"); // load() must not have created the file. assert!(!tmp.file_path().exists(), "load does not write"); } #[tokio::test] async fn upsert_round_trips_across_a_fresh_instance() { let tmp = TempDir::new(); { let store = FsLiveStateStore::new(&tmp.project_path()); store .upsert(entry(1, "building", WorkStatus::Working, 100)) .await .unwrap(); } // A fresh instance on the same root relits the persisted row. let store = FsLiveStateStore::new(&tmp.project_path()); let state = store.load().await.unwrap(); assert_eq!(state.entries.len(), 1); assert_eq!(state.entries[0].intent, "building"); assert_eq!(state.entries[0].status, WorkStatus::Working); // Persisted JSON is camelCase. let raw = std::fs::read_to_string(tmp.file_path()).unwrap(); assert!(raw.contains("\"agentId\""), "camelCase on disk: {raw}"); assert!(raw.contains("\"updatedAtMs\"")); } #[tokio::test] async fn write_is_atomic_no_tmp_residue() { let tmp = TempDir::new(); let store = FsLiveStateStore::new(&tmp.project_path()); store .upsert(entry(1, "a", WorkStatus::Working, 1)) .await .unwrap(); store .upsert(entry(2, "b", WorkStatus::Idle, 2)) .await .unwrap(); assert!(tmp.file_path().exists(), "final file present"); assert!( !tmp.tmp_path().exists(), "tmp file must be renamed away, not left behind" ); } #[tokio::test] async fn upsert_is_last_writer_wins_persisted() { let tmp = TempDir::new(); let store = FsLiveStateStore::new(&tmp.project_path()); store .upsert(entry(1, "first", WorkStatus::Working, 1)) .await .unwrap(); store .upsert(entry(1, "second", WorkStatus::Blocked, 2)) .await .unwrap(); let state = store.load().await.unwrap(); assert_eq!(state.entries.len(), 1, "same agent ⇒ one row, no duplicate"); assert_eq!(state.entries[0].intent, "second"); assert_eq!(state.entries[0].status, WorkStatus::Blocked); } #[tokio::test] async fn prune_ttl_and_max_n_persisted() { let tmp = TempDir::new(); let store = FsLiveStateStore::new(&tmp.project_path()); // Three rows at t=10/20/30; now=50, ttl=35 ⇒ t=10 (age 40>35) dropped, // t=20 (age 30) and t=30 (age 20) kept. store .upsert(entry(1, "old", WorkStatus::Idle, 10)) .await .unwrap(); store .upsert(entry(2, "mid", WorkStatus::Working, 20)) .await .unwrap(); store .upsert(entry(3, "new", WorkStatus::Working, 30)) .await .unwrap(); store.prune(50, 35, 100).await.unwrap(); let after_ttl = store.load().await.unwrap(); assert_eq!(after_ttl.entries.len(), 2, "TTL dropped the oldest"); // Now bound to 1 ⇒ keep the most recent (t=30). store.prune(50, 35, 1).await.unwrap(); let after_cap = store.load().await.unwrap(); assert_eq!(after_cap.entries.len(), 1); assert_eq!(after_cap.entries[0].updated_at_ms, 30); }