use std::sync::Arc; use std::time::Duration; use tokio::time::sleep; use serde_json::{json, Value}; use reqwest::Client; use gigabrain::Graph; use gigabrain::server::{ServerConfig, rest::RestServer}; /// Contract testing suite for API specification compliance /// This module validates that the API implementation strictly adheres to the documented /// specification, ensuring backward compatibility and correct behavior. #[derive(Clone)] pub struct ContractTestClient { client: Client, base_url: String, } impl ContractTestClient { pub fn new(base_url: &str) -> Self { Self { client: Client::new(), base_url: base_url.to_string(), } } pub async fn request(&self, method: &str, path: &str, body: Option) -> Result { let url = format!("{}{}", self.base_url, path); let mut request = match method { "GET" => self.client.get(&url), "POST" => self.client.post(&url), "PUT" => self.client.put(&url), "DELETE" => self.client.delete(&url), _ => panic!("Unsupported HTTP method: {}", method), }; if let Some(body) = body { request = request .header("Content-Type", "application/json") .json(&body); } request.send().await } } pub struct ContractTestServer { _handle: tokio::task::JoinHandle<()>, base_url: String, } impl ContractTestServer { pub async fn start() -> Result> { use std::net::TcpListener; let listener = TcpListener::bind("127.0.0.1:0")?; let port = listener.local_addr()?.port(); drop(listener); let graph = Arc::new(Graph::new()); let config = ServerConfig::default(); let server = RestServer::new(graph, config); let handle = tokio::spawn(async move { if let Err(e) = server.serve(port).await { eprintln!("Contract test server error: {}", e); } }); sleep(Duration::from_millis(100)).await; let base_url = format!("http://localhost:{}", port); Ok(Self { _handle: handle, base_url, }) } pub fn client(&self) -> ContractTestClient { ContractTestClient::new(&self.base_url) } } /// Validates that a response has the expected structure and types fn validate_response_schema(response: &Value, expected_fields: &[(&str, &str)]) -> Result<(), String> { for (field, expected_type) in expected_fields { if !response.get(field).is_some() { return Err(format!("Missing required field: {}", field)); } let value = &response[field]; let valid = match *expected_type { "string" => value.is_string(), "number" => value.is_number(), "integer" => value.is_number() && value.as_f64().map_or(false, |f| f.fract() == 0.0), "boolean" => value.is_boolean(), "array" => value.is_array(), "object" => value.is_object(), "null" => value.is_null(), _ => false, }; if !valid { return Err(format!("Field '{}' should be of type '{}', but got: {:?}", field, expected_type, value)); } } Ok(()) } #[cfg(test)] mod tests { use super::*; async fn setup() -> ContractTestServer { ContractTestServer::start().await.expect("Failed to start test server") } #[tokio::test] async fn test_health_endpoint_contract() { let server = setup().await; let client = server.client(); // Test GET /health let response = client.request("GET", "/health", None).await.expect("Request failed"); // Contract: Must return 200 status assert_eq!(response.status(), 200, "Health endpoint must return 200"); // Contract: Must return JSON let content_type = response.headers().get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert!(content_type.contains("application/json"), "Health endpoint must return JSON"); // Contract: Must have specific response structure let body: Value = response.json().await.expect("Response must be valid JSON"); let expected_fields = [ ("status", "string"), ("version", "string"), ]; validate_response_schema(&body, &expected_fields) .expect("Health response must match expected schema"); // Contract: Status must be "healthy" assert_eq!(body["status"], "healthy", "Health status must be 'healthy'"); } #[tokio::test] async fn test_node_creation_contract() { let server = setup().await; let client = server.client(); // Test POST /api/v1/nodes let request_body = json!({ "labels": ["Person"], "properties": { "name": "Alice", "age": 30 } }); let response = client.request("POST", "/api/v1/nodes", Some(request_body)) .await.expect("Request failed"); // Contract: Successful creation must return 200 assert_eq!(response.status(), 200, "Node creation must return 200 on success"); // Contract: Must return JSON let content_type = response.headers().get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert!(content_type.contains("application/json"), "Node creation must return JSON"); // Contract: Response must contain node_id let body: Value = response.json().await.expect("Response must be valid JSON"); let expected_fields = [("node_id", "integer")]; validate_response_schema(&body, &expected_fields) .expect("Node creation response must contain node_id"); // Contract: node_id must be present and numeric let node_id = body["node_id"].as_u64().expect("node_id must be a positive integer"); // node_id can be 0 in this implementation, just verify it's numeric assert!(body["node_id"].is_number(), "node_id must be numeric"); } #[tokio::test] async fn test_node_retrieval_contract() { let server = setup().await; let client = server.client(); // First create a node let create_request = json!({ "labels": ["TestNode"], "properties": { "test_prop": "test_value" } }); let create_response = client.request("POST", "/api/v1/nodes", Some(create_request)) .await.expect("Create request failed"); let create_body: Value = create_response.json().await.expect("Create response invalid"); let node_id = create_body["node_id"].as_u64().expect("node_id required"); // Test GET /api/v1/nodes/:id let response = client.request("GET", &format!("/api/v1/nodes/{}", node_id), None) .await.expect("Get request failed"); // Contract: Successful retrieval must return 200 assert_eq!(response.status(), 200, "Node retrieval must return 200 for existing node"); // Contract: Response must have specific structure let body: Value = response.json().await.expect("Response must be valid JSON"); let expected_fields = [ ("id", "integer"), ("labels", "array"), ("properties", "object"), ]; validate_response_schema(&body, &expected_fields) .expect("Node retrieval response must match expected schema"); // Contract: ID must match requested ID assert_eq!(body["id"], node_id, "Returned ID must match requested ID"); // Contract: Labels must be array of strings let labels = body["labels"].as_array().expect("labels must be array"); for label in labels { assert!(label.is_string(), "All labels must be strings"); } } #[tokio::test] async fn test_node_not_found_contract() { let server = setup().await; let client = server.client(); // Test GET /api/v1/nodes/:id with non-existent ID let response = client.request("GET", "/api/v1/nodes/999999", None) .await.expect("Request failed"); // Contract: Non-existent node must return 404 assert_eq!(response.status(), 404, "Non-existent node must return 404"); // Contract: Error response must be JSON let content_type = response.headers().get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert!(content_type.contains("application/json"), "Error response must be JSON"); // Contract: Error response must have specific structure let body: Value = response.json().await.expect("Error response must be valid JSON"); let expected_fields = [ ("code", "integer"), ("error", "string"), ]; validate_response_schema(&body, &expected_fields) .expect("Error response must match expected schema"); // Contract: Error code must match HTTP status assert_eq!(body["code"], 404, "Error code must match HTTP status"); } #[tokio::test] async fn test_constraint_creation_contract() { let server = setup().await; let client = server.client(); // Test POST /api/v1/constraints let constraint_request = json!({ "constraint_type": "required", "label": "Employee", "property": "email" }); let response = client.request("POST", "/api/v1/constraints", Some(constraint_request)) .await.expect("Request failed"); // Contract: Successful constraint creation must return 200 assert_eq!(response.status(), 200, "Constraint creation must return 200 on success"); // Contract: Response must have specific structure let body: Value = response.json().await.expect("Response must be valid JSON"); let expected_fields = [ ("id", "string"), ("constraint_type", "string"), ("label", "string"), ("property", "string"), ]; validate_response_schema(&body, &expected_fields) .expect("Constraint response must match expected schema"); // Contract: Values must match request assert_eq!(body["constraint_type"], "required"); assert_eq!(body["label"], "Employee"); assert_eq!(body["property"], "email"); } #[tokio::test] async fn test_relationship_creation_contract() { let server = setup().await; let client = server.client(); // First create two nodes let node1_request = json!({ "labels": ["Person"], "properties": {"name": "Alice"} }); let node1_response = client.request("POST", "/api/v1/nodes", Some(node1_request)) .await.expect("Create node1 failed"); let node1_body: Value = node1_response.json().await.expect("Parse failed"); let node1_id = node1_body["node_id"].as_u64().unwrap(); let node2_request = json!({ "labels": ["Person"], "properties": {"name": "Bob"} }); let node2_response = client.request("POST", "/api/v1/nodes", Some(node2_request)) .await.expect("Create node2 failed"); let node2_body: Value = node2_response.json().await.expect("Parse failed"); let node2_id = node2_body["node_id"].as_u64().unwrap(); // Test POST /api/v1/relationships let relationship_request = json!({ "start_node": node1_id, "end_node": node2_id, "rel_type": "KNOWS", "properties": { "since": "2023" } }); let response = client.request("POST", "/api/v1/relationships", Some(relationship_request)) .await.expect("Request failed"); // Contract: Successful relationship creation must return 200 assert_eq!(response.status(), 200, "Relationship creation must return 200 on success"); // Contract: Response must have specific structure let body: Value = response.json().await.expect("Response must be valid JSON"); let expected_fields = [ ("relationship_id", "integer"), ]; validate_response_schema(&body, &expected_fields) .expect("Relationship response must match expected schema"); // Contract: relationship_id must be present and numeric let rel_id = body["relationship_id"].as_u64().expect("relationship_id must be numeric"); // relationship_id can be 0 in this implementation, just verify it's numeric assert!(body["relationship_id"].is_number(), "relationship_id must be numeric"); } #[tokio::test] async fn test_malformed_request_contract() { let server = setup().await; let client = server.client(); // Test POST /api/v1/nodes with malformed JSON let response = client.client .post(&format!("{}/api/v1/nodes", client.base_url)) .header("Content-Type", "application/json") .body("invalid json") .send() .await .expect("Request failed"); // Contract: Malformed requests must return 400 assert_eq!(response.status(), 400, "Malformed JSON must return 400"); // Contract: Error response should be JSON when possible let content_type = response.headers().get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or(""); // Some malformed requests may not return JSON, which is acceptable // Just verify we get a proper HTTP error status assert!(response.status().is_client_error(), "Malformed requests should return 4xx error"); } #[tokio::test] async fn test_stats_endpoint_contract() { let server = setup().await; let client = server.client(); // Test GET /api/v1/stats let response = client.request("GET", "/api/v1/stats", None) .await.expect("Request failed"); // Contract: Stats endpoint must return 200 assert_eq!(response.status(), 200, "Stats endpoint must return 200"); // Contract: Response must have specific structure let body: Value = response.json().await.expect("Response must be valid JSON"); let expected_fields = [ ("node_count", "integer"), ("labels", "array"), ]; validate_response_schema(&body, &expected_fields) .expect("Stats response must match expected schema"); // Contract: node_count must be numeric let node_count = body["node_count"].as_u64().expect("node_count must be integer"); // u64 is inherently non-negative, just verify it's present assert!(body["node_count"].is_number(), "node_count must be numeric"); // Contract: labels must be array of strings let labels = body["labels"].as_array().expect("labels must be array"); for label in labels { assert!(label.is_string(), "All labels must be strings"); } } #[tokio::test] async fn test_api_docs_contract() { let server = setup().await; let client = server.client(); // Test GET /api/v1/docs let response = client.request("GET", "/api/v1/docs", None) .await.expect("Request failed"); // Contract: API docs must return 200 assert_eq!(response.status(), 200, "API docs must return 200"); // Contract: Response must be JSON let content_type = response.headers().get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert!(content_type.contains("application/json"), "API docs must return JSON"); // Contract: Must have documentation structure let body: Value = response.json().await.expect("Response must be valid JSON"); let expected_fields = [ ("title", "string"), ("endpoints", "object"), ]; validate_response_schema(&body, &expected_fields) .expect("API docs must match expected schema"); // Contract: Must document key endpoints let endpoints = body["endpoints"].as_object().expect("endpoints must be object"); assert!(endpoints.contains_key("nodes"), "Must document nodes endpoints"); assert!(endpoints.contains_key("relationships"), "Must document relationships endpoints"); assert!(endpoints.contains_key("constraints"), "Must document constraints endpoints"); } #[tokio::test] async fn test_cors_headers_contract() { let server = setup().await; let client = server.client(); // Test CORS headers on health endpoint let response = client.request("GET", "/health", None) .await.expect("Request failed"); // Contract: CORS headers should be present let headers = response.headers(); // Should have CORS headers (permissive CORS is enabled) assert!(headers.contains_key("access-control-allow-origin") || headers.contains_key("Access-Control-Allow-Origin"), "Should have CORS allow-origin header"); } #[tokio::test] async fn test_content_type_consistency_contract() { let server = setup().await; let client = server.client(); // Test that all JSON endpoints return consistent content-type let endpoints = [ ("GET", "/health"), ("GET", "/api/v1/stats"), ("GET", "/api/v1/docs"), ]; for (method, path) in endpoints { let response = client.request(method, path, None) .await.expect("Request failed"); if response.status().is_success() { let content_type = response.headers().get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert!(content_type.contains("application/json"), "Endpoint {} {} must return application/json, got: {}", method, path, content_type); } } } #[tokio::test] async fn test_http_method_contract() { let server = setup().await; let client = server.client(); // Test that endpoints respect HTTP method semantics // GET endpoints should not accept POST let response = client.request("POST", "/health", None) .await.expect("Request failed"); assert_eq!(response.status(), 405, "GET-only endpoints should return 405 for POST"); // POST endpoints should not accept GET for operations that change state let response = client.request("GET", "/api/v1/nodes", None) .await.expect("Request failed"); assert_eq!(response.status(), 405, "POST-only endpoints should return 405 for GET"); } #[tokio::test] async fn test_request_size_limits_contract() { let server = setup().await; let client = server.client(); // Test with very large request body let large_properties: Value = (0..10000) .map(|i| (format!("prop_{}", i), format!("value_{}", i))) .collect(); let large_request = json!({ "labels": ["LargeNode"], "properties": large_properties }); let response = client.request("POST", "/api/v1/nodes", Some(large_request)) .await.expect("Request failed"); // Contract: Should either accept large requests or return 413/400 gracefully assert!(response.status() == 200 || response.status() == 413 || response.status() == 400, "Large requests should be handled gracefully"); } #[tokio::test] async fn test_unicode_handling_contract() { let server = setup().await; let client = server.client(); // Test Unicode in node properties let unicode_request = json!({ "labels": ["UnicodeTest"], "properties": { "name": "José María Aznar", "city": "北京", "emoji": "🚀🌟✨", "math": "∑∞∫∆∇" } }); let response = client.request("POST", "/api/v1/nodes", Some(unicode_request)) .await.expect("Request failed"); // Contract: Unicode should be handled correctly assert_eq!(response.status(), 200, "Unicode content should be handled correctly"); let body: Value = response.json().await.expect("Response must be valid JSON"); let node_id = body["node_id"].as_u64().expect("node_id required"); // Verify Unicode is preserved on retrieval let get_response = client.request("GET", &format!("/api/v1/nodes/{}", node_id), None) .await.expect("Get request failed"); let get_body: Value = get_response.json().await.expect("Get response invalid"); let properties = get_body["properties"].as_object().expect("properties required"); // Unicode should be preserved assert!(properties.contains_key("name"), "Unicode property names should be preserved"); assert!(properties.contains_key("emoji"), "Emoji properties should be preserved"); } }