this repo has no description
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}