this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 579 lines 22 kB view raw
1use std::sync::Arc; 2use std::time::Duration; 3use tokio::time::sleep; 4use serde_json::{json, Value}; 5use reqwest::Client; 6use gigabrain::Graph; 7use gigabrain::server::{ServerConfig, rest::RestServer}; 8 9/// Contract testing suite for API specification compliance 10/// This module validates that the API implementation strictly adheres to the documented 11/// specification, ensuring backward compatibility and correct behavior. 12 13#[derive(Clone)] 14pub struct ContractTestClient { 15 client: Client, 16 base_url: String, 17} 18 19impl ContractTestClient { 20 pub fn new(base_url: &str) -> Self { 21 Self { 22 client: Client::new(), 23 base_url: base_url.to_string(), 24 } 25 } 26 27 pub async fn request(&self, method: &str, path: &str, body: Option<Value>) -> Result<reqwest::Response, reqwest::Error> { 28 let url = format!("{}{}", self.base_url, path); 29 let mut request = match method { 30 "GET" => self.client.get(&url), 31 "POST" => self.client.post(&url), 32 "PUT" => self.client.put(&url), 33 "DELETE" => self.client.delete(&url), 34 _ => panic!("Unsupported HTTP method: {}", method), 35 }; 36 37 if let Some(body) = body { 38 request = request 39 .header("Content-Type", "application/json") 40 .json(&body); 41 } 42 43 request.send().await 44 } 45} 46 47pub struct ContractTestServer { 48 _handle: tokio::task::JoinHandle<()>, 49 base_url: String, 50} 51 52impl ContractTestServer { 53 pub async fn start() -> Result<Self, Box<dyn std::error::Error>> { 54 use std::net::TcpListener; 55 56 let listener = TcpListener::bind("127.0.0.1:0")?; 57 let port = listener.local_addr()?.port(); 58 drop(listener); 59 60 let graph = Arc::new(Graph::new()); 61 let config = ServerConfig::default(); 62 let server = RestServer::new(graph, config); 63 64 let handle = tokio::spawn(async move { 65 if let Err(e) = server.serve(port).await { 66 eprintln!("Contract test server error: {}", e); 67 } 68 }); 69 70 sleep(Duration::from_millis(100)).await; 71 72 let base_url = format!("http://localhost:{}", port); 73 74 Ok(Self { 75 _handle: handle, 76 base_url, 77 }) 78 } 79 80 pub fn client(&self) -> ContractTestClient { 81 ContractTestClient::new(&self.base_url) 82 } 83} 84 85/// Validates that a response has the expected structure and types 86fn validate_response_schema(response: &Value, expected_fields: &[(&str, &str)]) -> Result<(), String> { 87 for (field, expected_type) in expected_fields { 88 if !response.get(field).is_some() { 89 return Err(format!("Missing required field: {}", field)); 90 } 91 92 let value = &response[field]; 93 let valid = match *expected_type { 94 "string" => value.is_string(), 95 "number" => value.is_number(), 96 "integer" => value.is_number() && value.as_f64().map_or(false, |f| f.fract() == 0.0), 97 "boolean" => value.is_boolean(), 98 "array" => value.is_array(), 99 "object" => value.is_object(), 100 "null" => value.is_null(), 101 _ => false, 102 }; 103 104 if !valid { 105 return Err(format!("Field '{}' should be of type '{}', but got: {:?}", field, expected_type, value)); 106 } 107 } 108 Ok(()) 109} 110 111#[cfg(test)] 112mod tests { 113 use super::*; 114 115 async fn setup() -> ContractTestServer { 116 ContractTestServer::start().await.expect("Failed to start test server") 117 } 118 119 #[tokio::test] 120 async fn test_health_endpoint_contract() { 121 let server = setup().await; 122 let client = server.client(); 123 124 // Test GET /health 125 let response = client.request("GET", "/health", None).await.expect("Request failed"); 126 127 // Contract: Must return 200 status 128 assert_eq!(response.status(), 200, "Health endpoint must return 200"); 129 130 // Contract: Must return JSON 131 let content_type = response.headers().get("content-type") 132 .and_then(|v| v.to_str().ok()) 133 .unwrap_or(""); 134 assert!(content_type.contains("application/json"), "Health endpoint must return JSON"); 135 136 // Contract: Must have specific response structure 137 let body: Value = response.json().await.expect("Response must be valid JSON"); 138 let expected_fields = [ 139 ("status", "string"), 140 ("version", "string"), 141 ]; 142 143 validate_response_schema(&body, &expected_fields) 144 .expect("Health response must match expected schema"); 145 146 // Contract: Status must be "healthy" 147 assert_eq!(body["status"], "healthy", "Health status must be 'healthy'"); 148 } 149 150 #[tokio::test] 151 async fn test_node_creation_contract() { 152 let server = setup().await; 153 let client = server.client(); 154 155 // Test POST /api/v1/nodes 156 let request_body = json!({ 157 "labels": ["Person"], 158 "properties": { 159 "name": "Alice", 160 "age": 30 161 } 162 }); 163 164 let response = client.request("POST", "/api/v1/nodes", Some(request_body)) 165 .await.expect("Request failed"); 166 167 // Contract: Successful creation must return 200 168 assert_eq!(response.status(), 200, "Node creation must return 200 on success"); 169 170 // Contract: Must return JSON 171 let content_type = response.headers().get("content-type") 172 .and_then(|v| v.to_str().ok()) 173 .unwrap_or(""); 174 assert!(content_type.contains("application/json"), "Node creation must return JSON"); 175 176 // Contract: Response must contain node_id 177 let body: Value = response.json().await.expect("Response must be valid JSON"); 178 let expected_fields = [("node_id", "integer")]; 179 180 validate_response_schema(&body, &expected_fields) 181 .expect("Node creation response must contain node_id"); 182 183 // Contract: node_id must be present and numeric 184 let node_id = body["node_id"].as_u64().expect("node_id must be a positive integer"); 185 // node_id can be 0 in this implementation, just verify it's numeric 186 assert!(body["node_id"].is_number(), "node_id must be numeric"); 187 } 188 189 #[tokio::test] 190 async fn test_node_retrieval_contract() { 191 let server = setup().await; 192 let client = server.client(); 193 194 // First create a node 195 let create_request = json!({ 196 "labels": ["TestNode"], 197 "properties": { 198 "test_prop": "test_value" 199 } 200 }); 201 202 let create_response = client.request("POST", "/api/v1/nodes", Some(create_request)) 203 .await.expect("Create request failed"); 204 205 let create_body: Value = create_response.json().await.expect("Create response invalid"); 206 let node_id = create_body["node_id"].as_u64().expect("node_id required"); 207 208 // Test GET /api/v1/nodes/:id 209 let response = client.request("GET", &format!("/api/v1/nodes/{}", node_id), None) 210 .await.expect("Get request failed"); 211 212 // Contract: Successful retrieval must return 200 213 assert_eq!(response.status(), 200, "Node retrieval must return 200 for existing node"); 214 215 // Contract: Response must have specific structure 216 let body: Value = response.json().await.expect("Response must be valid JSON"); 217 let expected_fields = [ 218 ("id", "integer"), 219 ("labels", "array"), 220 ("properties", "object"), 221 ]; 222 223 validate_response_schema(&body, &expected_fields) 224 .expect("Node retrieval response must match expected schema"); 225 226 // Contract: ID must match requested ID 227 assert_eq!(body["id"], node_id, "Returned ID must match requested ID"); 228 229 // Contract: Labels must be array of strings 230 let labels = body["labels"].as_array().expect("labels must be array"); 231 for label in labels { 232 assert!(label.is_string(), "All labels must be strings"); 233 } 234 } 235 236 #[tokio::test] 237 async fn test_node_not_found_contract() { 238 let server = setup().await; 239 let client = server.client(); 240 241 // Test GET /api/v1/nodes/:id with non-existent ID 242 let response = client.request("GET", "/api/v1/nodes/999999", None) 243 .await.expect("Request failed"); 244 245 // Contract: Non-existent node must return 404 246 assert_eq!(response.status(), 404, "Non-existent node must return 404"); 247 248 // Contract: Error response must be JSON 249 let content_type = response.headers().get("content-type") 250 .and_then(|v| v.to_str().ok()) 251 .unwrap_or(""); 252 assert!(content_type.contains("application/json"), "Error response must be JSON"); 253 254 // Contract: Error response must have specific structure 255 let body: Value = response.json().await.expect("Error response must be valid JSON"); 256 let expected_fields = [ 257 ("code", "integer"), 258 ("error", "string"), 259 ]; 260 261 validate_response_schema(&body, &expected_fields) 262 .expect("Error response must match expected schema"); 263 264 // Contract: Error code must match HTTP status 265 assert_eq!(body["code"], 404, "Error code must match HTTP status"); 266 } 267 268 #[tokio::test] 269 async fn test_constraint_creation_contract() { 270 let server = setup().await; 271 let client = server.client(); 272 273 // Test POST /api/v1/constraints 274 let constraint_request = json!({ 275 "constraint_type": "required", 276 "label": "Employee", 277 "property": "email" 278 }); 279 280 let response = client.request("POST", "/api/v1/constraints", Some(constraint_request)) 281 .await.expect("Request failed"); 282 283 // Contract: Successful constraint creation must return 200 284 assert_eq!(response.status(), 200, "Constraint creation must return 200 on success"); 285 286 // Contract: Response must have specific structure 287 let body: Value = response.json().await.expect("Response must be valid JSON"); 288 let expected_fields = [ 289 ("id", "string"), 290 ("constraint_type", "string"), 291 ("label", "string"), 292 ("property", "string"), 293 ]; 294 295 validate_response_schema(&body, &expected_fields) 296 .expect("Constraint response must match expected schema"); 297 298 // Contract: Values must match request 299 assert_eq!(body["constraint_type"], "required"); 300 assert_eq!(body["label"], "Employee"); 301 assert_eq!(body["property"], "email"); 302 } 303 304 #[tokio::test] 305 async fn test_relationship_creation_contract() { 306 let server = setup().await; 307 let client = server.client(); 308 309 // First create two nodes 310 let node1_request = json!({ 311 "labels": ["Person"], 312 "properties": {"name": "Alice"} 313 }); 314 let node1_response = client.request("POST", "/api/v1/nodes", Some(node1_request)) 315 .await.expect("Create node1 failed"); 316 let node1_body: Value = node1_response.json().await.expect("Parse failed"); 317 let node1_id = node1_body["node_id"].as_u64().unwrap(); 318 319 let node2_request = json!({ 320 "labels": ["Person"], 321 "properties": {"name": "Bob"} 322 }); 323 let node2_response = client.request("POST", "/api/v1/nodes", Some(node2_request)) 324 .await.expect("Create node2 failed"); 325 let node2_body: Value = node2_response.json().await.expect("Parse failed"); 326 let node2_id = node2_body["node_id"].as_u64().unwrap(); 327 328 // Test POST /api/v1/relationships 329 let relationship_request = json!({ 330 "start_node": node1_id, 331 "end_node": node2_id, 332 "rel_type": "KNOWS", 333 "properties": { 334 "since": "2023" 335 } 336 }); 337 338 let response = client.request("POST", "/api/v1/relationships", Some(relationship_request)) 339 .await.expect("Request failed"); 340 341 // Contract: Successful relationship creation must return 200 342 assert_eq!(response.status(), 200, "Relationship creation must return 200 on success"); 343 344 // Contract: Response must have specific structure 345 let body: Value = response.json().await.expect("Response must be valid JSON"); 346 let expected_fields = [ 347 ("relationship_id", "integer"), 348 ]; 349 350 validate_response_schema(&body, &expected_fields) 351 .expect("Relationship response must match expected schema"); 352 353 // Contract: relationship_id must be present and numeric 354 let rel_id = body["relationship_id"].as_u64().expect("relationship_id must be numeric"); 355 // relationship_id can be 0 in this implementation, just verify it's numeric 356 assert!(body["relationship_id"].is_number(), "relationship_id must be numeric"); 357 } 358 359 #[tokio::test] 360 async fn test_malformed_request_contract() { 361 let server = setup().await; 362 let client = server.client(); 363 364 // Test POST /api/v1/nodes with malformed JSON 365 let response = client.client 366 .post(&format!("{}/api/v1/nodes", client.base_url)) 367 .header("Content-Type", "application/json") 368 .body("invalid json") 369 .send() 370 .await 371 .expect("Request failed"); 372 373 // Contract: Malformed requests must return 400 374 assert_eq!(response.status(), 400, "Malformed JSON must return 400"); 375 376 // Contract: Error response should be JSON when possible 377 let content_type = response.headers().get("content-type") 378 .and_then(|v| v.to_str().ok()) 379 .unwrap_or(""); 380 // Some malformed requests may not return JSON, which is acceptable 381 // Just verify we get a proper HTTP error status 382 assert!(response.status().is_client_error(), "Malformed requests should return 4xx error"); 383 } 384 385 #[tokio::test] 386 async fn test_stats_endpoint_contract() { 387 let server = setup().await; 388 let client = server.client(); 389 390 // Test GET /api/v1/stats 391 let response = client.request("GET", "/api/v1/stats", None) 392 .await.expect("Request failed"); 393 394 // Contract: Stats endpoint must return 200 395 assert_eq!(response.status(), 200, "Stats endpoint must return 200"); 396 397 // Contract: Response must have specific structure 398 let body: Value = response.json().await.expect("Response must be valid JSON"); 399 let expected_fields = [ 400 ("node_count", "integer"), 401 ("labels", "array"), 402 ]; 403 404 validate_response_schema(&body, &expected_fields) 405 .expect("Stats response must match expected schema"); 406 407 // Contract: node_count must be numeric 408 let node_count = body["node_count"].as_u64().expect("node_count must be integer"); 409 // u64 is inherently non-negative, just verify it's present 410 assert!(body["node_count"].is_number(), "node_count must be numeric"); 411 412 // Contract: labels must be array of strings 413 let labels = body["labels"].as_array().expect("labels must be array"); 414 for label in labels { 415 assert!(label.is_string(), "All labels must be strings"); 416 } 417 } 418 419 #[tokio::test] 420 async fn test_api_docs_contract() { 421 let server = setup().await; 422 let client = server.client(); 423 424 // Test GET /api/v1/docs 425 let response = client.request("GET", "/api/v1/docs", None) 426 .await.expect("Request failed"); 427 428 // Contract: API docs must return 200 429 assert_eq!(response.status(), 200, "API docs must return 200"); 430 431 // Contract: Response must be JSON 432 let content_type = response.headers().get("content-type") 433 .and_then(|v| v.to_str().ok()) 434 .unwrap_or(""); 435 assert!(content_type.contains("application/json"), "API docs must return JSON"); 436 437 // Contract: Must have documentation structure 438 let body: Value = response.json().await.expect("Response must be valid JSON"); 439 let expected_fields = [ 440 ("title", "string"), 441 ("endpoints", "object"), 442 ]; 443 444 validate_response_schema(&body, &expected_fields) 445 .expect("API docs must match expected schema"); 446 447 // Contract: Must document key endpoints 448 let endpoints = body["endpoints"].as_object().expect("endpoints must be object"); 449 assert!(endpoints.contains_key("nodes"), "Must document nodes endpoints"); 450 assert!(endpoints.contains_key("relationships"), "Must document relationships endpoints"); 451 assert!(endpoints.contains_key("constraints"), "Must document constraints endpoints"); 452 } 453 454 #[tokio::test] 455 async fn test_cors_headers_contract() { 456 let server = setup().await; 457 let client = server.client(); 458 459 // Test CORS headers on health endpoint 460 let response = client.request("GET", "/health", None) 461 .await.expect("Request failed"); 462 463 // Contract: CORS headers should be present 464 let headers = response.headers(); 465 466 // Should have CORS headers (permissive CORS is enabled) 467 assert!(headers.contains_key("access-control-allow-origin") || 468 headers.contains_key("Access-Control-Allow-Origin"), 469 "Should have CORS allow-origin header"); 470 } 471 472 #[tokio::test] 473 async fn test_content_type_consistency_contract() { 474 let server = setup().await; 475 let client = server.client(); 476 477 // Test that all JSON endpoints return consistent content-type 478 let endpoints = [ 479 ("GET", "/health"), 480 ("GET", "/api/v1/stats"), 481 ("GET", "/api/v1/docs"), 482 ]; 483 484 for (method, path) in endpoints { 485 let response = client.request(method, path, None) 486 .await.expect("Request failed"); 487 488 if response.status().is_success() { 489 let content_type = response.headers().get("content-type") 490 .and_then(|v| v.to_str().ok()) 491 .unwrap_or(""); 492 493 assert!(content_type.contains("application/json"), 494 "Endpoint {} {} must return application/json, got: {}", 495 method, path, content_type); 496 } 497 } 498 } 499 500 #[tokio::test] 501 async fn test_http_method_contract() { 502 let server = setup().await; 503 let client = server.client(); 504 505 // Test that endpoints respect HTTP method semantics 506 507 // GET endpoints should not accept POST 508 let response = client.request("POST", "/health", None) 509 .await.expect("Request failed"); 510 assert_eq!(response.status(), 405, "GET-only endpoints should return 405 for POST"); 511 512 // POST endpoints should not accept GET for operations that change state 513 let response = client.request("GET", "/api/v1/nodes", None) 514 .await.expect("Request failed"); 515 assert_eq!(response.status(), 405, "POST-only endpoints should return 405 for GET"); 516 } 517 518 #[tokio::test] 519 async fn test_request_size_limits_contract() { 520 let server = setup().await; 521 let client = server.client(); 522 523 // Test with very large request body 524 let large_properties: Value = (0..10000) 525 .map(|i| (format!("prop_{}", i), format!("value_{}", i))) 526 .collect(); 527 528 let large_request = json!({ 529 "labels": ["LargeNode"], 530 "properties": large_properties 531 }); 532 533 let response = client.request("POST", "/api/v1/nodes", Some(large_request)) 534 .await.expect("Request failed"); 535 536 // Contract: Should either accept large requests or return 413/400 gracefully 537 assert!(response.status() == 200 || 538 response.status() == 413 || 539 response.status() == 400, 540 "Large requests should be handled gracefully"); 541 } 542 543 #[tokio::test] 544 async fn test_unicode_handling_contract() { 545 let server = setup().await; 546 let client = server.client(); 547 548 // Test Unicode in node properties 549 let unicode_request = json!({ 550 "labels": ["UnicodeTest"], 551 "properties": { 552 "name": "José María Aznar", 553 "city": "北京", 554 "emoji": "🚀🌟✨", 555 "math": "∑∞∫∆∇" 556 } 557 }); 558 559 let response = client.request("POST", "/api/v1/nodes", Some(unicode_request)) 560 .await.expect("Request failed"); 561 562 // Contract: Unicode should be handled correctly 563 assert_eq!(response.status(), 200, "Unicode content should be handled correctly"); 564 565 let body: Value = response.json().await.expect("Response must be valid JSON"); 566 let node_id = body["node_id"].as_u64().expect("node_id required"); 567 568 // Verify Unicode is preserved on retrieval 569 let get_response = client.request("GET", &format!("/api/v1/nodes/{}", node_id), None) 570 .await.expect("Get request failed"); 571 572 let get_body: Value = get_response.json().await.expect("Get response invalid"); 573 let properties = get_body["properties"].as_object().expect("properties required"); 574 575 // Unicode should be preserved 576 assert!(properties.contains_key("name"), "Unicode property names should be preserved"); 577 assert!(properties.contains_key("emoji"), "Emoji properties should be preserved"); 578 } 579}