use std::collections::HashMap; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::ports::{ChatDraftStore, Clock, ProjectStore, StoreError}; use domain::{ ChatDraft, ChatDraftKey, LayoutId, NodeId, Project, ProjectId, ProjectPath, RemoteRef, }; use uuid::Uuid; use application::{ ChatDraftScopeInput, ClearChatDraft, ReadChatDraft, SaveChatDraft, SaveChatDraftInput, }; #[derive(Default)] struct FakeProjects { projects: Mutex>, } #[async_trait] impl ProjectStore for FakeProjects { async fn list_projects(&self) -> Result, StoreError> { Ok(self.projects.lock().unwrap().values().cloned().collect()) } async fn load_project(&self, id: ProjectId) -> Result { self.projects .lock() .unwrap() .get(&id) .cloned() .ok_or(StoreError::NotFound) } async fn save_project(&self, project: &Project) -> Result<(), StoreError> { self.projects .lock() .unwrap() .insert(project.id, project.clone()); Ok(()) } async fn save_workspace( &self, _workspace: &domain::layout::Workspace, ) -> Result<(), StoreError> { Ok(()) } async fn load_workspace(&self) -> Result { Err(StoreError::NotFound) } } #[derive(Default)] struct FakeDrafts { drafts: Mutex>, saves: Mutex>, clears: Mutex>, } #[async_trait] impl ChatDraftStore for FakeDrafts { async fn read( &self, _root: &ProjectPath, key: ChatDraftKey, ) -> Result, StoreError> { Ok(self.drafts.lock().unwrap().get(&key).cloned()) } async fn save(&self, _root: &ProjectPath, draft: ChatDraft) -> Result<(), StoreError> { self.saves.lock().unwrap().push(draft.clone()); self.drafts.lock().unwrap().insert(draft.key, draft); Ok(()) } async fn clear(&self, _root: &ProjectPath, key: ChatDraftKey) -> Result<(), StoreError> { self.clears.lock().unwrap().push(key); self.drafts.lock().unwrap().remove(&key); Ok(()) } } struct FixedClock; impl Clock for FixedClock { fn now_millis(&self) -> i64 { 1234 } } fn pid() -> ProjectId { ProjectId::from_uuid(Uuid::from_u128(1)) } fn lid(n: u128) -> LayoutId { LayoutId::from_uuid(Uuid::from_u128(n)) } fn nid(n: u128) -> NodeId { NodeId::from_uuid(Uuid::from_u128(n)) } fn project() -> Project { Project::new( pid(), "P", ProjectPath::new("/tmp/idea-chat-drafts-test").unwrap(), RemoteRef::Local, 0, ) .unwrap() } fn scope(layout_id: LayoutId, node_id: NodeId) -> ChatDraftScopeInput { ChatDraftScopeInput { project_id: pid(), layout_id, node_id, } } #[tokio::test] async fn save_and_read_chat_draft_round_trip_text_only() { let projects = Arc::new(FakeProjects::default()); projects.save_project(&project()).await.unwrap(); let drafts = Arc::new(FakeDrafts::default()); let save = SaveChatDraft::new( Arc::clone(&projects) as Arc, Arc::clone(&drafts) as Arc, Arc::new(FixedClock), ); let read = ReadChatDraft::new( Arc::clone(&projects) as Arc, Arc::clone(&drafts) as Arc, ); save.execute(SaveChatDraftInput { scope: scope(lid(2), nid(3)), text: "hello draft".to_owned(), }) .await .unwrap(); let output = read.execute(scope(lid(2), nid(3))).await.unwrap(); assert_eq!(output.text.as_deref(), Some("hello draft")); assert_eq!(output.updated_at_ms, Some(1234)); assert_eq!(drafts.saves.lock().unwrap()[0].text, "hello draft"); } #[tokio::test] async fn save_empty_text_clears_draft() { let projects = Arc::new(FakeProjects::default()); projects.save_project(&project()).await.unwrap(); let drafts = Arc::new(FakeDrafts::default()); let key = ChatDraftKey::new(pid(), lid(2), nid(3)); drafts .drafts .lock() .unwrap() .insert(key, ChatDraft::new(key, "old", 1).unwrap()); let save = SaveChatDraft::new( Arc::clone(&projects) as Arc, Arc::clone(&drafts) as Arc, Arc::new(FixedClock), ); save.execute(SaveChatDraftInput { scope: scope(lid(2), nid(3)), text: String::new(), }) .await .unwrap(); assert!(drafts.drafts.lock().unwrap().is_empty()); assert_eq!(drafts.clears.lock().unwrap().as_slice(), &[key]); } #[tokio::test] async fn clear_chat_draft_is_scoped_by_layout_and_node() { let projects = Arc::new(FakeProjects::default()); projects.save_project(&project()).await.unwrap(); let drafts = Arc::new(FakeDrafts::default()); let keep = ChatDraftKey::new(pid(), lid(2), nid(4)); let clear = ChatDraftKey::new(pid(), lid(2), nid(3)); drafts .drafts .lock() .unwrap() .insert(keep, ChatDraft::new(keep, "keep", 1).unwrap()); drafts .drafts .lock() .unwrap() .insert(clear, ChatDraft::new(clear, "clear", 1).unwrap()); let usecase = ClearChatDraft::new( Arc::clone(&projects) as Arc, Arc::clone(&drafts) as Arc, ); usecase.execute(scope(lid(2), nid(3))).await.unwrap(); let drafts = drafts.drafts.lock().unwrap(); assert!(drafts.contains_key(&keep)); assert!(!drafts.contains_key(&clear)); } #[tokio::test] async fn oversized_chat_draft_is_rejected_before_store_call() { let projects = Arc::new(FakeProjects::default()); projects.save_project(&project()).await.unwrap(); let drafts = Arc::new(FakeDrafts::default()); let save = SaveChatDraft::new( Arc::clone(&projects) as Arc, Arc::clone(&drafts) as Arc, Arc::new(FixedClock), ); let err = save .execute(SaveChatDraftInput { scope: scope(lid(2), nid(3)), text: "x".repeat(domain::CHAT_DRAFT_MAX_BYTES + 1), }) .await .unwrap_err(); assert_eq!(err.code(), "INVALID"); assert!(drafts.saves.lock().unwrap().is_empty()); }