diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml index b909510..0e3b486 100644 --- a/.github/workflows/cargo-audit.yml +++ b/.github/workflows/cargo-audit.yml @@ -15,6 +15,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - - uses: rustsec/audit-check@v2 + - uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/crates/llm-router/Cargo.toml b/crates/llm-router/Cargo.toml index d3f7f89..60a8ed9 100644 --- a/crates/llm-router/Cargo.toml +++ b/crates/llm-router/Cargo.toml @@ -13,5 +13,5 @@ thiserror = { workspace = true } reqwest = { version = "0.12", features = ["json", "rustls-tls"] } async-trait = "0.1" tracing = "0.1" -dashmap = "7.0" -"tokio::sync" = { version = "1.44", features = ["RwLock"] } +dashmap = "7.0.0-rc2" +"tokio/sync" = { version = "1.44", features = ["RwLock"] } diff --git a/tests/llm_router_test.rs b/tests/llm_router_test.rs new file mode 100644 index 0000000..75975b7 --- /dev/null +++ b/tests/llm_router_test.rs @@ -0,0 +1,174 @@ +// Integration tests for llm-router crate +// Traces to: FR-001 + +use llm_router::{ + CompletionRequest, CompletionResponse, LlmError, LlmRouter, LlmProvider, Message, TokenUsage, +}; + +/// Mock provider for testing +struct MockProvider { + name: String, + should_fail: bool, +} + +impl MockProvider { + fn new(name: &str) -> Self { + Self { + name: name.to_string(), + should_fail: false, + } + } + + fn failing(name: &str) -> Self { + Self { + name: name.to_string(), + should_fail: true, + } + } +} + +#[::async_trait::async_trait] +impl LlmProvider for MockProvider { + async fn complete( + &self, + request: &CompletionRequest, + ) -> Result { + if self.should_fail { + return Err(LlmError::Provider("Mock failure".to_string())); + } + + Ok(CompletionResponse { + content: format!("Mock response for model: {}", request.model), + model: request.model.clone(), + provider: self.name.clone(), + usage: TokenUsage { + prompt_tokens: 10, + completion_tokens: 20, + total_tokens: 30, + }, + latency_ms: 100, + }) + } + + fn provider_name(&self) -> &str { + &self.name + } +} + +#[test] +fn test_completion_request_serialization() { + let request = CompletionRequest { + model: "gpt-4".to_string(), + messages: vec![ + Message { + role: "user".to_string(), + content: "Hello".to_string(), + }, + ], + temperature: Some(0.7), + max_tokens: Some(100), + timeout_ms: Some(30000), + }; + + let json = serde_json::to_string(&request).expect("Should serialize"); + assert!(json.contains("gpt-4")); + assert!(json.contains("Hello")); + + let deserialized: CompletionRequest = + serde_json::from_str(&json).expect("Should deserialize"); + assert_eq!(deserialized.model, "gpt-4"); +} + +#[test] +fn test_completion_response_serialization() { + let response = CompletionResponse { + content: "Test response".to_string(), + model: "gpt-4".to_string(), + provider: "openai".to_string(), + usage: TokenUsage { + prompt_tokens: 10, + completion_tokens: 20, + total_tokens: 30, + }, + latency_ms: 150, + }; + + let json = serde_json::to_string(&response).expect("Should serialize"); + assert!(json.contains("Test response")); + + let deserialized: CompletionResponse = + serde_json::from_str(&json).expect("Should deserialize"); + assert_eq!(deserialized.content, "Test response"); + assert_eq!(deserialized.usage.total_tokens, 30); +} + +#[test] +fn test_llm_router_creation() { + let router = LlmRouter::new(); + assert!(router.providers.is_empty()); + assert!(router.fallback.is_none()); +} + +#[test] +fn test_llm_router_register_provider() { + use std::sync::Arc; + + let router = LlmRouter::new(); + let provider = Arc::new(MockProvider::new("test-provider")); + + router.register_provider("test", provider); + + assert_eq!(router.providers.len(), 1); + assert!(router.providers.contains_key("test")); +} + +#[test] +fn test_llm_router_set_fallback() { + use std::sync::Arc; + + let router = LlmRouter::new(); + let fallback = Arc::new(MockProvider::new("fallback")); + + router.set_fallback(fallback); + + assert!(router.fallback.is_some()); +} + +#[test] +fn test_llm_error_display() { + let err = LlmError::Provider("test error".to_string()); + assert_eq!(format!("{}", err), "provider error: test error"); + + let err = LlmError::RateLimited; + assert_eq!(format!("{}", err), "rate limited"); + + let err = LlmError::Timeout; + assert_eq!(format!("{}", err), "timeout"); + + let err = LlmError::InvalidModel("gpt-5".to_string()); + assert_eq!(format!("{}", err), "invalid model: gpt-5"); +} + +#[test] +fn test_message_creation() { + let msg = Message { + role: "assistant".to_string(), + content: "I am here to help".to_string(), + }; + + assert_eq!(msg.role, "assistant"); + assert_eq!(msg.content, "I am here to help"); +} + +#[test] +fn test_token_usage() { + let usage = TokenUsage { + prompt_tokens: 100, + completion_tokens: 200, + total_tokens: 300, + }; + + assert_eq!(usage.prompt_tokens, 100); + assert_eq!(usage.completion_tokens, 200); + assert_eq!(usage.total_tokens, 300); +} diff --git a/tests/mcp_server_test.rs b/tests/mcp_server_test.rs new file mode 100644 index 0000000..4de437f --- /dev/null +++ b/tests/mcp_server_test.rs @@ -0,0 +1,233 @@ +// Integration tests for mcp-server crate +// Traces to: FR-001 + +use mcp_server::{ContentItem, McpError, McpServer, Resource, Tool, ToolResult}; + +#[tokio::test] +async fn test_mcp_server_creation() { + let server = McpServer::new(); + let tools = server.list_tools().await; + let resources = server.list_resources().await; + + assert!(tools.is_empty()); + assert!(resources.is_empty()); +} + +#[tokio::test] +async fn test_register_tool() { + let server = McpServer::new(); + + let tool = Tool { + name: "test_tool".to_string(), + description: "A test tool".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "input": {"type": "string"} + } + }), + }; + + server + .register_tool(tool, |args| { + Ok(serde_json::json!({"result": "success"})) + }) + .await; + + let tools = server.list_tools().await; + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].name, "test_tool"); +} + +#[tokio::test] +async fn test_register_multiple_tools() { + let server = McpServer::new(); + + for i in 0..5 { + let tool = Tool { + name: format!("tool_{}", i), + description: format!("Tool number {}", i), + input_schema: serde_json::json!({"type": "object"}), + }; + + server + .register_tool(tool, move |_| Ok(serde_json::json!({"id": i}))) + .await; + } + + let tools = server.list_tools().await; + assert_eq!(tools.len(), 5); +} + +#[tokio::test] +async fn test_call_tool() { + let server = McpServer::new(); + + let tool = Tool { + name: "echo".to_string(), + description: "Echoes the input".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "message": {"type": "string"} + } + }), + }; + + server + .register_tool(tool, |args| { + let msg = args.get("message").and_then(|v| v.as_str()).unwrap_or(""); + Ok(serde_json::json!({"echoed": msg})) + }) + .await; + + let result = server + .call_tool("echo", serde_json::json!({"message": "hello"})) + .await + .expect("Should succeed"); + + assert!(!result.is_error); + assert_eq!(result.content.len(), 1); + assert_eq!(result.content[0].content_type, "text"); +} + +#[tokio::test] +async fn test_call_nonexistent_tool() { + let server = McpServer::new(); + + let result = server + .call_tool("nonexistent", serde_json::json!({})) + .await; + + assert!(result.is_err()); + match result { + Err(McpError::ToolNotFound(name)) => assert_eq!(name, "nonexistent"), + _ => panic!("Expected ToolNotFound error"), + } +} + +#[tokio::test] +async fn test_register_resource() { + let server = McpServer::new(); + + let resource = Resource { + uri: "file:///test/data.txt".to_string(), + name: "test_data".to_string(), + mime_type: Some("text/plain".to_string()), + }; + + server.register_resource(resource).await; + + let resources = server.list_resources().await; + assert_eq!(resources.len(), 1); + assert_eq!(resources[0].uri, "file:///test/data.txt"); +} + +#[tokio::test] +async fn test_read_resource() { + let server = McpServer::new(); + + let resource = Resource { + uri: "file:///test/readable.txt".to_string(), + name: "readable_file".to_string(), + mime_type: Some("text/plain".to_string()), + }; + + server.register_resource(resource).await; + + let content = server + .read_resource("file:///test/readable.txt") + .await + .expect("Should read resource"); + + assert!(content.contains("readable_file")); +} + +#[tokio::test] +async fn test_read_nonexistent_resource() { + let server = McpServer::new(); + + let result = server.read_resource("file:///nonexistent").await; + + assert!(result.is_err()); + match result { + Err(McpError::ResourceNotFound(uri)) => assert_eq!(uri, "file:///nonexistent"), + _ => panic!("Expected ResourceNotFound error"), + } +} + +#[test] +fn test_tool_serialization() { + let tool = Tool { + name: "test".to_string(), + description: "A test".to_string(), + input_schema: serde_json::json!({"type": "object"}), + }; + + let json = serde_json::to_string(&tool).expect("Should serialize"); + let deserialized: Tool = serde_json::from_str(&json).expect("Should deserialize"); + + assert_eq!(deserialized.name, "test"); + assert_eq!(deserialized.description, "A test"); +} + +#[test] +fn test_tool_result_serialization() { + let result = ToolResult { + content: vec![ + ContentItem { + content_type: "text".to_string(), + text: Some("Hello".to_string()), + }, + ], + is_error: false, + }; + + let json = serde_json::to_string(&result).expect("Should serialize"); + let deserialized: ToolResult = serde_json::from_str(&json).expect("Should deserialize"); + + assert!(!deserialized.is_error); + assert_eq!(deserialized.content.len(), 1); + assert_eq!(deserialized.content[0].text, Some("Hello".to_string())); +} + +#[test] +fn test_resource_serialization() { + let resource = Resource { + uri: "test://resource".to_string(), + name: "my_resource".to_string(), + mime_type: Some("application/json".to_string()), + }; + + let json = serde_json::to_string(&resource).expect("Should serialize"); + let deserialized: Resource = serde_json::from_str(&json).expect("Should deserialize"); + + assert_eq!(deserialized.uri, "test://resource"); + assert_eq!(deserialized.mime_type, Some("application/json".to_string())); +} + +#[test] +fn test_mcp_error_display() { + let err = McpError::ToolNotFound("tool".to_string()); + assert_eq!(format!("{}", err), "tool not found: tool"); + + let err = McpError::ResourceNotFound("resource".to_string()); + assert_eq!(format!("{}", err), "resource not found: resource"); + + let err = McpError::InvalidRequest("bad input".to_string()); + assert_eq!(format!("{}", err), "invalid request: bad input"); +} + +#[test] +fn test_content_item_serialization() { + let item = ContentItem { + content_type: "text".to_string(), + text: Some("Sample text".to_string()), + }; + + let json = serde_json::to_string(&item).expect("Should serialize"); + let deserialized: ContentItem = serde_json::from_str(&json).expect("Should deserialize"); + + assert_eq!(deserialized.content_type, "text"); + assert_eq!(deserialized.text, Some("Sample text".to_string())); +} diff --git a/tests/pheno_embedding_test.rs b/tests/pheno_embedding_test.rs new file mode 100644 index 0000000..e67782c --- /dev/null +++ b/tests/pheno_embedding_test.rs @@ -0,0 +1,141 @@ +// Integration tests for pheno-embedding crate +// Traces to: FR-001 + +use pheno_embedding::{EmbeddingRequest, EmbeddingResponse, TokenUsage}; + +#[test] +fn test_embedding_request_creation() { + let request = EmbeddingRequest { + texts: vec!["Hello world".to_string(), "Rust is great".to_string()], + model: Some("text-embedding-3-small".to_string()), + }; + + assert_eq!(request.texts.len(), 2); + assert_eq!(request.model, Some("text-embedding-3-small".to_string())); +} + +#[test] +fn test_embedding_request_default_model() { + let request = EmbeddingRequest { + texts: vec!["Test text".to_string()], + model: None, + }; + + assert!(request.model.is_none()); +} + +#[test] +fn test_embedding_response_creation() { + let response = EmbeddingResponse { + embeddings: vec![ + vec![0.1, 0.2, 0.3], + vec![0.4, 0.5, 0.6], + ], + model: "text-embedding-3-small".to_string(), + usage: TokenUsage { total_tokens: 100 }, + }; + + assert_eq!(response.embeddings.len(), 2); + assert_eq!(response.embeddings[0].len(), 3); + assert_eq!(response.model, "text-embedding-3-small"); + assert_eq!(response.usage.total_tokens, 100); +} + +#[test] +fn test_token_usage() { + let usage = TokenUsage { total_tokens: 500 }; + + assert_eq!(usage.total_tokens, 500); +} + +#[test] +fn test_embedding_request_serialization() { + let request = EmbeddingRequest { + texts: vec!["First".to_string(), "Second".to_string()], + model: Some("embed-model".to_string()), + }; + + let json = serde_json::to_string(&request).expect("Should serialize"); + assert!(json.contains("First")); + assert!(json.contains("Second")); + assert!(json.contains("embed-model")); + + let deserialized: EmbeddingRequest = + serde_json::from_str(&json).expect("Should deserialize"); + assert_eq!(deserialized.texts.len(), 2); +} + +#[test] +fn test_embedding_response_serialization() { + let response = EmbeddingResponse { + embeddings: vec![vec![0.1, 0.2, 0.3]], + model: "test-model".to_string(), + usage: TokenUsage { total_tokens: 50 }, + }; + + let json = serde_json::to_string(&response).expect("Should serialize"); + assert!(json.contains("test-model")); + assert!(json.contains("embeddings")); + + let deserialized: EmbeddingResponse = + serde_json::from_str(&json).expect("Should deserialize"); + assert_eq!(deserialized.model, "test-model"); + assert_eq!(deserialized.usage.total_tokens, 50); +} + +#[test] +fn test_single_text_embedding() { + let request = EmbeddingRequest { + texts: vec!["Single text input".to_string()], + model: None, + }; + + assert_eq!(request.texts.len(), 1); + assert_eq!(request.texts[0], "Single text input"); +} + +#[test] +fn test_large_batch_embedding_request() { + let texts: Vec = (0..100).map(|i| format!("Text number {}", i)).collect(); + + let request = EmbeddingRequest { + texts, + model: Some("text-embedding-3-large".to_string()), + }; + + assert_eq!(request.texts.len(), 100); + assert_eq!(request.texts[99], "Text number 99"); +} + +#[test] +fn test_empty_texts_embedding() { + let request = EmbeddingRequest { + texts: vec![], + model: None, + }; + + assert!(request.texts.is_empty()); +} + +#[test] +fn test_embedding_vector_dimensions() { + // Test various embedding dimension sizes + let small_embedding = vec![0.1; 384]; + let medium_embedding = vec![0.2; 1536]; + let large_embedding = vec![0.3; 3072]; + + assert_eq!(small_embedding.len(), 384); + assert_eq!(medium_embedding.len(), 1536); + assert_eq!(large_embedding.len(), 3072); + + let response = EmbeddingResponse { + embeddings: vec![small_embedding, medium_embedding, large_embedding], + model: "multi-model".to_string(), + usage: TokenUsage { total_tokens: 1000 }, + }; + + assert_eq!(response.embeddings.len(), 3); + assert_eq!(response.embeddings[0].len(), 384); + assert_eq!(response.embeddings[1].len(), 1536); + assert_eq!(response.embeddings[2].len(), 3072); +}