use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::ports::{ ChatAttachmentImport, ChatAttachmentStore, ChatAttachmentStoreError, Clock, IdGenerator, }; use domain::{ AgentId, ChatAttachment, ChatAttachmentSourceKind, LocalPath, Project, ProjectId, ProjectPath, RemoteRef, SessionId, }; use uuid::Uuid; use application::{ImportChatAttachmentItem, ImportChatAttachments, ImportChatAttachmentsInput}; #[derive(Default)] struct FakeChatAttachments { calls: Mutex>, } #[async_trait] impl ChatAttachmentStore for FakeChatAttachments { async fn import_from_path( &self, root: &ProjectPath, project_id: ProjectId, agent_id: AgentId, session_id: SessionId, source: &LocalPath, import: ChatAttachmentImport, ) -> Result { self.calls .lock() .unwrap() .push((source.clone(), import.clone())); let storage_path = format!( "agent-chat/{session_id}/{}-{}", import.id.as_str(), import.filename ); let readable_path = format!( "{}/.ideai/attachments/agent-chat/{session_id}/{}-{}", root.as_str(), import.id.as_str(), import.filename ); Ok(ChatAttachment { id: import.id, project_id, agent_id, session_id, filename: import.filename.clone(), mime: import.mime.clone(), size_bytes: 12, source_kind: import.source_kind, storage_path, readable_path, created_at: import.created_at, }) } async fn list_for_session( &self, _root: &ProjectPath, _session_id: SessionId, ) -> Result, ChatAttachmentStoreError> { Ok(Vec::new()) } } struct FixedClock; impl Clock for FixedClock { fn now_millis(&self) -> i64 { 1_234 } } struct FixedIds; impl IdGenerator for FixedIds { fn new_uuid(&self) -> Uuid { Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap() } } fn project() -> Project { Project::new( ProjectId::from_uuid(Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap()), "P", ProjectPath::new("/tmp/idea-chat-attachments-test").unwrap(), RemoteRef::Local, 0, ) .unwrap() } #[tokio::test] async fn import_chat_attachments_allocates_metadata_and_uses_store_port() { let store = Arc::new(FakeChatAttachments::default()); let usecase = ImportChatAttachments::new( Arc::clone(&store) as Arc, Arc::new(FixedIds), Arc::new(FixedClock), ); let agent_id = AgentId::from_uuid(Uuid::parse_str("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb").unwrap()); let session_id = SessionId::from_uuid(Uuid::parse_str("cccccccc-cccc-cccc-cccc-cccccccccccc").unwrap()); let output = usecase .execute(ImportChatAttachmentsInput { project: project(), agent_id, session_id, attachments: vec![ImportChatAttachmentItem { path: "/outside/photo.png".to_owned(), mime: None, source_kind: ChatAttachmentSourceKind::Clipboard, }], }) .await .unwrap(); assert_eq!(output.attachments.len(), 1); let attachment = &output.attachments[0]; assert_eq!(attachment.filename, "photo.png"); assert_eq!(attachment.mime, "image/png"); assert_eq!(attachment.source_kind, ChatAttachmentSourceKind::Clipboard); assert_eq!(attachment.created_at, 1_234); assert!(attachment .storage_path .starts_with("agent-chat/cccccccc-cccc-cccc-cccc-cccccccccccc/")); assert!(attachment .readable_path .contains("/.ideai/attachments/agent-chat/")); let calls = store.calls.lock().unwrap(); assert_eq!(calls.len(), 1); assert_eq!(calls[0].0.as_str(), "/outside/photo.png"); } #[tokio::test] async fn import_chat_attachments_rejects_executable_sources_before_store_call() { let store = Arc::new(FakeChatAttachments::default()); let usecase = ImportChatAttachments::new( Arc::clone(&store) as Arc, Arc::new(FixedIds), Arc::new(FixedClock), ); let err = usecase .execute(ImportChatAttachmentsInput { project: project(), agent_id: AgentId::new_random(), session_id: SessionId::new_random(), attachments: vec![ImportChatAttachmentItem { path: "/outside/run.sh".to_owned(), mime: None, source_kind: ChatAttachmentSourceKind::LocalFile, }], }) .await .unwrap_err(); assert_eq!(err.code(), "INVALID"); assert!(store.calls.lock().unwrap().is_empty()); }