use domain::ports::{ ChatAttachmentImport, ChatAttachmentStore, ChatAttachmentStoreError, IdGenerator, }; use domain::{ AgentId, ChatAttachmentId, ChatAttachmentSourceKind, LocalPath, ProjectId, ProjectPath, SessionId, }; use infrastructure::{FsChatAttachmentStore, UuidGenerator}; fn temp_dir(tag: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!( "idea-chat-attachment-{tag}-{}", UuidGenerator::new().new_uuid() )) } #[tokio::test] async fn fs_chat_attachment_store_imports_outside_file_to_project_managed_path() { let project_root = temp_dir("project"); let outside_root = temp_dir("outside"); tokio::fs::create_dir_all(&project_root).await.unwrap(); tokio::fs::create_dir_all(&outside_root).await.unwrap(); let source = outside_root.join("picked.png"); tokio::fs::write(&source, b"png bytes").await.unwrap(); let store = FsChatAttachmentStore::new(); let project_path = ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(); let project_id = ProjectId::new_random(); let agent_id = AgentId::new_random(); let session_id = SessionId::new_random(); let attachment_id = ChatAttachmentId::new("attach-1").unwrap(); let attachment = store .import_from_path( &project_path, project_id, agent_id, session_id, &LocalPath::new(source.to_string_lossy().into_owned()), ChatAttachmentImport { id: attachment_id, filename: "picked.png".to_owned(), mime: "image/png".to_owned(), source_kind: ChatAttachmentSourceKind::LocalFile, created_at: 42, }, ) .await .unwrap(); assert_eq!(attachment.project_id, project_id); assert_eq!(attachment.agent_id, agent_id); assert_eq!(attachment.session_id, session_id); assert_eq!(attachment.filename, "picked.png"); assert_eq!(attachment.size_bytes, 9); assert!(attachment .storage_path .starts_with(&format!("agent-chat/{session_id}/"))); assert!(attachment .readable_path .starts_with(&format!("{}/.ideai/attachments/", project_root.display()))); let stored_bytes = tokio::fs::read(&attachment.readable_path).await.unwrap(); assert_eq!(stored_bytes, b"png bytes"); let listed = store .list_for_session(&project_path, session_id) .await .unwrap(); assert_eq!(listed, vec![attachment]); let _ = tokio::fs::remove_dir_all(project_root).await; let _ = tokio::fs::remove_dir_all(outside_root).await; } #[tokio::test] async fn fs_chat_attachment_store_imports_bytes_to_project_managed_path() { let project_root = temp_dir("project-bytes"); tokio::fs::create_dir_all(&project_root).await.unwrap(); let store = FsChatAttachmentStore::new(); let project_path = ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(); let project_id = ProjectId::new_random(); let agent_id = AgentId::new_random(); let session_id = SessionId::new_random(); let attachment_id = ChatAttachmentId::new("attach-bytes").unwrap(); let attachment = store .import_from_bytes( &project_path, project_id, agent_id, session_id, b"clipboard png bytes", ChatAttachmentImport { id: attachment_id, filename: "clipboard.png".to_owned(), mime: "image/png".to_owned(), source_kind: ChatAttachmentSourceKind::Clipboard, created_at: 43, }, ) .await .unwrap(); assert_eq!(attachment.project_id, project_id); assert_eq!(attachment.agent_id, agent_id); assert_eq!(attachment.session_id, session_id); assert_eq!(attachment.filename, "clipboard.png"); assert_eq!(attachment.size_bytes, 19); assert_eq!(attachment.source_kind, ChatAttachmentSourceKind::Clipboard); assert!(attachment .readable_path .starts_with(&format!("{}/.ideai/attachments/", project_root.display()))); let stored_bytes = tokio::fs::read(&attachment.readable_path).await.unwrap(); assert_eq!(stored_bytes, b"clipboard png bytes"); let listed = store .list_for_session(&project_path, session_id) .await .unwrap(); assert_eq!(listed, vec![attachment]); let _ = tokio::fs::remove_dir_all(project_root).await; } #[tokio::test] async fn fs_chat_attachment_store_rejects_directories() { let project_root = temp_dir("project-dir"); let outside_root = temp_dir("outside-dir"); tokio::fs::create_dir_all(&project_root).await.unwrap(); tokio::fs::create_dir_all(&outside_root).await.unwrap(); let store = FsChatAttachmentStore::new(); let err = store .import_from_path( &ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(), ProjectId::new_random(), AgentId::new_random(), SessionId::new_random(), &LocalPath::new(outside_root.to_string_lossy().into_owned()), ChatAttachmentImport { id: ChatAttachmentId::new("attach-2").unwrap(), filename: "outside-dir".to_owned(), mime: "application/octet-stream".to_owned(), source_kind: ChatAttachmentSourceKind::LocalFile, created_at: 42, }, ) .await .unwrap_err(); assert!(matches!(err, ChatAttachmentStoreError::Invalid(_))); let _ = tokio::fs::remove_dir_all(project_root).await; let _ = tokio::fs::remove_dir_all(outside_root).await; } #[tokio::test] async fn fs_chat_attachment_store_rejects_empty_bytes() { let project_root = temp_dir("project-empty-bytes"); tokio::fs::create_dir_all(&project_root).await.unwrap(); let store = FsChatAttachmentStore::new(); let err = store .import_from_bytes( &ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(), ProjectId::new_random(), AgentId::new_random(), SessionId::new_random(), &[], ChatAttachmentImport { id: ChatAttachmentId::new("attach-empty").unwrap(), filename: "empty.png".to_owned(), mime: "image/png".to_owned(), source_kind: ChatAttachmentSourceKind::Clipboard, created_at: 42, }, ) .await .unwrap_err(); assert!(matches!(err, ChatAttachmentStoreError::Invalid(_))); let _ = tokio::fs::remove_dir_all(project_root).await; }