Files
IdeA/crates/application/tests/chat_attachments.rs
Blomios 790c4755be feat(chat): #155 paste image depuis presse-papier dans le composer custom (QA verte)
- frontend: onPaste sur CustomAgentChatView, détection MIME image, chip/preview d'attachment, envoi via le contrat #154 (adapters/agent, ports)

- backend: import d'image par bytes dans le store attachments (commands, chat_attachments app+infra, dto, ports) + tests
2026-08-06 11:01:50 +02:00

250 lines
7.6 KiB
Rust

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, ImportChatAttachmentSource, ImportChatAttachments,
ImportChatAttachmentsInput,
};
#[derive(Default)]
struct FakeChatAttachments {
path_calls: Mutex<Vec<(LocalPath, ChatAttachmentImport)>>,
bytes_calls: Mutex<Vec<(Vec<u8>, ChatAttachmentImport)>>,
}
#[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<ChatAttachment, ChatAttachmentStoreError> {
self.path_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 import_from_bytes(
&self,
root: &ProjectPath,
project_id: ProjectId,
agent_id: AgentId,
session_id: SessionId,
bytes: &[u8],
import: ChatAttachmentImport,
) -> Result<ChatAttachment, ChatAttachmentStoreError> {
self.bytes_calls
.lock()
.unwrap()
.push((bytes.to_vec(), 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: bytes.len() as u64,
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<Vec<ChatAttachment>, 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<dyn ChatAttachmentStore>,
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 {
source: ImportChatAttachmentSource::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.path_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_routes_clipboard_bytes_to_store_port() {
let store = Arc::new(FakeChatAttachments::default());
let usecase = ImportChatAttachments::new(
Arc::clone(&store) as Arc<dyn ChatAttachmentStore>,
Arc::new(FixedIds),
Arc::new(FixedClock),
);
let output = usecase
.execute(ImportChatAttachmentsInput {
project: project(),
agent_id: AgentId::new_random(),
session_id: SessionId::new_random(),
attachments: vec![ImportChatAttachmentItem {
source: ImportChatAttachmentSource::Bytes {
filename: "clipboard.png".to_owned(),
bytes: b"png bytes".to_vec(),
},
mime: Some("image/png".to_owned()),
source_kind: ChatAttachmentSourceKind::Clipboard,
}],
})
.await
.unwrap();
assert_eq!(output.attachments[0].filename, "clipboard.png");
assert_eq!(output.attachments[0].mime, "image/png");
assert_eq!(
output.attachments[0].source_kind,
ChatAttachmentSourceKind::Clipboard
);
let calls = store.bytes_calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, b"png bytes");
assert_eq!(calls[0].1.filename, "clipboard.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<dyn ChatAttachmentStore>,
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 {
source: ImportChatAttachmentSource::Path("/outside/run.sh".to_owned()),
mime: None,
source_kind: ChatAttachmentSourceKind::LocalFile,
}],
})
.await
.unwrap_err();
assert_eq!(err.code(), "INVALID");
assert!(store.path_calls.lock().unwrap().is_empty());
assert!(store.bytes_calls.lock().unwrap().is_empty());
}