1use crate::models::{Protocol, RecordedRequest, RecordedResponse};
7use crate::query::{execute_query, QueryFilter};
8use crate::{RecorderDatabase, RecorderError, Result};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::collections::HashMap;
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(rename_all = "snake_case")]
16pub enum TestFormat {
17 RustReqwest,
19 HttpFile,
21 Curl,
23 Postman,
25 K6,
27 PythonPytest,
29 JavaScriptJest,
31 GoTest,
33 RubyRspec,
35 JavaJunit,
37 CSharpXunit,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct TestGenerationConfig {
44 pub format: TestFormat,
46 pub include_assertions: bool,
48 pub validate_body: bool,
50 pub validate_status: bool,
52 pub validate_headers: bool,
54 pub validate_timing: bool,
56 pub max_duration_ms: Option<u64>,
58 pub suite_name: String,
60 pub base_url: Option<String>,
62 pub ai_descriptions: bool,
64 pub llm_config: Option<LlmConfig>,
66 pub group_by_endpoint: bool,
68 pub include_setup_teardown: bool,
70 pub generate_fixtures: bool,
72 pub suggest_edge_cases: bool,
74 pub analyze_test_gaps: bool,
76 pub deduplicate_tests: bool,
78 pub optimize_test_order: bool,
80}
81
82impl Default for TestGenerationConfig {
83 fn default() -> Self {
84 Self {
85 format: TestFormat::RustReqwest,
86 include_assertions: true,
87 validate_body: true,
88 validate_status: true,
89 validate_headers: false,
90 validate_timing: false,
91 max_duration_ms: None,
92 suite_name: "generated_tests".to_string(),
93 base_url: Some("http://localhost:3000".to_string()),
94 ai_descriptions: false,
95 llm_config: None,
96 group_by_endpoint: true,
97 include_setup_teardown: true,
98 generate_fixtures: false,
99 suggest_edge_cases: false,
100 analyze_test_gaps: false,
101 deduplicate_tests: false,
102 optimize_test_order: false,
103 }
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct LlmConfig {
110 pub provider: String,
112 pub api_endpoint: String,
114 pub api_key: Option<String>,
116 pub model: String,
118 pub temperature: f64,
120}
121
122impl Default for LlmConfig {
123 fn default() -> Self {
124 Self {
125 provider: "ollama".to_string(),
126 api_endpoint: "http://localhost:11434/api/generate".to_string(),
127 api_key: None,
128 model: "llama2".to_string(),
129 temperature: 0.3,
130 }
131 }
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct GeneratedTest {
137 pub name: String,
139 pub description: String,
141 pub code: String,
143 pub request_id: String,
145 pub endpoint: String,
147 pub method: String,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct TestGenerationResult {
154 pub tests: Vec<GeneratedTest>,
156 pub metadata: TestSuiteMetadata,
158 pub test_file: String,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct TestSuiteMetadata {
165 pub name: String,
167 pub test_count: usize,
169 pub endpoint_count: usize,
171 pub protocols: Vec<Protocol>,
173 pub generated_at: chrono::DateTime<chrono::Utc>,
175 pub format: TestFormat,
177 pub fixtures: Option<Vec<TestFixture>>,
179 pub edge_cases: Option<Vec<EdgeCaseSuggestion>>,
181 pub gap_analysis: Option<TestGapAnalysis>,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct TestFixture {
188 pub name: String,
190 pub description: String,
192 pub data: Value,
194 pub endpoints: Vec<String>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct EdgeCaseSuggestion {
201 pub endpoint: String,
203 pub method: String,
205 pub case_type: String,
207 pub description: String,
209 pub suggested_input: Option<Value>,
211 pub expected_behavior: String,
213 pub priority: u8,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct TestGapAnalysis {
220 pub untested_endpoints: Vec<String>,
222 pub missing_methods: HashMap<String, Vec<String>>,
224 pub missing_status_codes: HashMap<String, Vec<u16>>,
226 pub missing_error_scenarios: Vec<String>,
228 pub coverage_percentage: f64,
230 pub recommendations: Vec<String>,
232}
233
234pub struct TestGenerator {
236 database: RecorderDatabase,
237 config: TestGenerationConfig,
238}
239
240impl TestGenerator {
241 pub fn new(database: RecorderDatabase, config: TestGenerationConfig) -> Self {
243 Self { database, config }
244 }
245
246 pub fn from_arc(
248 database: std::sync::Arc<RecorderDatabase>,
249 config: TestGenerationConfig,
250 ) -> Self {
251 Self {
252 database: (*database).clone(),
253 config,
254 }
255 }
256
257 pub async fn generate_from_filter(&self, filter: QueryFilter) -> Result<TestGenerationResult> {
259 let query_result = execute_query(&self.database, filter).await?;
261
262 if query_result.exchanges.is_empty() {
263 return Err(RecorderError::InvalidFilter(
264 "No recordings found matching the filter".to_string(),
265 ));
266 }
267
268 let mut tests = Vec::new();
270 let mut endpoints = std::collections::HashSet::new();
271 let mut protocols = std::collections::HashSet::new();
272
273 for exchange in &query_result.exchanges {
274 let request = &exchange.request;
275
276 let Some(response) = &exchange.response else {
278 continue;
279 };
280
281 endpoints.insert(format!("{} {}", request.method, request.path));
282 protocols.insert(request.protocol);
283
284 let test = self.generate_test_for_exchange(request, response).await?;
285 tests.push(test);
286 }
287
288 if self.config.deduplicate_tests {
290 tests = self.deduplicate_tests(tests);
291 }
292
293 if self.config.optimize_test_order {
295 tests = self.optimize_test_order(tests);
296 }
297
298 if self.config.group_by_endpoint {
300 tests.sort_by(|a, b| a.endpoint.cmp(&b.endpoint));
301 }
302
303 let fixtures = if self.config.generate_fixtures {
305 Some(self.generate_test_fixtures(&query_result.exchanges).await?)
306 } else {
307 None
308 };
309
310 let edge_cases = if self.config.suggest_edge_cases {
311 Some(self.suggest_edge_cases(&query_result.exchanges).await?)
312 } else {
313 None
314 };
315
316 let gap_analysis = if self.config.analyze_test_gaps {
317 Some(self.analyze_test_gaps(&query_result.exchanges, &tests).await?)
318 } else {
319 None
320 };
321
322 let test_file = self.generate_test_file(&tests)?;
324
325 let metadata = TestSuiteMetadata {
327 name: self.config.suite_name.clone(),
328 test_count: tests.len(),
329 endpoint_count: endpoints.len(),
330 protocols: protocols.into_iter().collect(),
331 generated_at: chrono::Utc::now(),
332 format: self.config.format.clone(),
333 fixtures,
334 edge_cases,
335 gap_analysis,
336 };
337
338 Ok(TestGenerationResult {
339 tests,
340 metadata,
341 test_file,
342 })
343 }
344
345 async fn generate_test_for_exchange(
347 &self,
348 request: &RecordedRequest,
349 response: &RecordedResponse,
350 ) -> Result<GeneratedTest> {
351 let test_name = self.generate_test_name(request);
352 let description = if self.config.ai_descriptions {
353 self.generate_ai_description(request, response).await?
354 } else {
355 format!("Test {} {}", request.method, request.path)
356 };
357
358 let code = match self.config.format {
359 TestFormat::RustReqwest => self.generate_rust_test(request, response)?,
360 TestFormat::HttpFile => self.generate_http_file(request, response)?,
361 TestFormat::Curl => self.generate_curl(request, response)?,
362 TestFormat::Postman => self.generate_postman(request, response)?,
363 TestFormat::K6 => self.generate_k6(request, response)?,
364 TestFormat::PythonPytest => self.generate_python_test(request, response)?,
365 TestFormat::JavaScriptJest => self.generate_javascript_test(request, response)?,
366 TestFormat::GoTest => self.generate_go_test(request, response)?,
367 TestFormat::RubyRspec => self.generate_ruby_test(request, response)?,
368 TestFormat::JavaJunit => self.generate_java_test(request, response)?,
369 TestFormat::CSharpXunit => self.generate_csharp_test(request, response)?,
370 };
371
372 Ok(GeneratedTest {
373 name: test_name,
374 description,
375 code,
376 request_id: request.id.clone(),
377 endpoint: request.path.clone(),
378 method: request.method.clone(),
379 })
380 }
381
382 fn generate_test_name(&self, request: &RecordedRequest) -> String {
384 let method = request.method.to_lowercase();
385 let path = request
386 .path
387 .trim_start_matches('/')
388 .replace(['/', '-'], "_")
389 .replace("{", "")
390 .replace("}", "");
391
392 format!("test_{}_{}", method, path)
393 }
394
395 async fn generate_ai_description(
397 &self,
398 request: &RecordedRequest,
399 response: &RecordedResponse,
400 ) -> Result<String> {
401 if let Some(llm_config) = &self.config.llm_config {
402 let prompt = format!(
404 "Generate a concise test description for this API call:\n\
405 Method: {}\n\
406 Path: {}\n\
407 Status: {}\n\
408 \n\
409 Describe what this endpoint does and what the test validates in one sentence.",
410 request.method, request.path, response.status_code
411 );
412
413 match self.call_llm(llm_config, &prompt).await {
414 Ok(description) => Ok(description),
415 Err(_) => Ok(format!("Test {} {}", request.method, request.path)),
416 }
417 } else {
418 Ok(format!("Test {} {}", request.method, request.path))
419 }
420 }
421
422 async fn call_llm(&self, config: &LlmConfig, prompt: &str) -> Result<String> {
424 let client = reqwest::Client::new();
425
426 match config.provider.as_str() {
427 "ollama" => {
428 let body = serde_json::json!({
429 "model": config.model,
430 "prompt": prompt,
431 "stream": false,
432 "options": {
433 "temperature": config.temperature
434 }
435 });
436
437 let response = client
438 .post(&config.api_endpoint)
439 .json(&body)
440 .send()
441 .await
442 .map_err(|e| RecorderError::Replay(format!("LLM request failed: {}", e)))?;
443
444 let result: Value = response.json().await.map_err(|e| {
445 RecorderError::Replay(format!("Failed to parse JSON response: {}", e))
446 })?;
447
448 result
449 .get("response")
450 .and_then(|v| v.as_str())
451 .map(|s| s.trim().to_string())
452 .ok_or_else(|| RecorderError::Replay("Invalid LLM response".to_string()))
453 }
454 "openai" => {
455 let body = serde_json::json!({
456 "model": config.model,
457 "messages": [
458 {"role": "system", "content": "You are a helpful assistant that generates concise test descriptions."},
459 {"role": "user", "content": prompt}
460 ],
461 "temperature": config.temperature,
462 "max_tokens": 100
463 });
464
465 let mut request_builder = client.post(&config.api_endpoint).json(&body);
466
467 if let Some(api_key) = &config.api_key {
468 request_builder =
469 request_builder.header("Authorization", format!("Bearer {}", api_key));
470 }
471
472 let response = request_builder
473 .send()
474 .await
475 .map_err(|e| RecorderError::Replay(format!("LLM request failed: {}", e)))?;
476
477 let result: Value = response.json().await.map_err(|e| {
478 RecorderError::Replay(format!("Failed to parse JSON response: {}", e))
479 })?;
480
481 result
482 .get("choices")
483 .and_then(|v| v.get(0))
484 .and_then(|v| v.get("message"))
485 .and_then(|v| v.get("content"))
486 .and_then(|v| v.as_str())
487 .map(|s| s.trim().to_string())
488 .ok_or_else(|| RecorderError::Replay("Invalid LLM response".to_string()))
489 }
490 _ => {
491 Err(RecorderError::Replay(format!("Unsupported LLM provider: {}", config.provider)))
492 }
493 }
494 }
495
496 fn generate_rust_test(
498 &self,
499 request: &RecordedRequest,
500 response: &RecordedResponse,
501 ) -> Result<String> {
502 let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
503 let url = format!("{}{}", base_url, request.path);
504
505 let mut code = String::new();
506 let test_name = self.generate_test_name(request);
507
508 code.push_str("#[tokio::test]\n");
509 code.push_str(&format!("async fn {}() {{\n", test_name));
510 code.push_str(" let client = reqwest::Client::new();\n");
511 code.push_str(&format!(
512 " let response = client.{}(\"{}\")\n",
513 request.method.to_lowercase(),
514 url
515 ));
516
517 if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
519 for (key, value) in headers.iter() {
520 if key.to_lowercase() != "host" {
521 code.push_str(&format!(" .header(\"{}\", \"{}\")\n", key, value));
522 }
523 }
524 }
525
526 if let Some(body) = &request.body {
528 if !body.is_empty() {
529 code.push_str(&format!(" .body(r#\"{}\"#)\n", body));
530 }
531 }
532
533 code.push_str(" .send()\n");
534 code.push_str(" .await\n");
535 code.push_str(" .expect(\"Failed to send request\");\n\n");
536
537 if self.config.validate_status {
539 code.push_str(&format!(
540 " assert_eq!(response.status().as_u16(), {});\n",
541 response.status_code
542 ));
543 }
544
545 if self.config.validate_body && response.body.is_some() {
546 code.push_str(
547 " let body = response.text().await.expect(\"Failed to read body\");\n",
548 );
549 if let Some(body) = &response.body {
550 if let Ok(_json) = serde_json::from_str::<Value>(body) {
552 code.push_str(" let json: serde_json::Value = serde_json::from_str(&body).expect(\"Invalid JSON\");\n");
553 code.push_str(" // Validate response structure\n");
554 code.push_str(" assert!(json.is_object() || json.is_array());\n");
555 }
556 }
557 }
558
559 if self.config.validate_timing {
560 if let Some(max_duration) = self.config.max_duration_ms {
561 code.push_str(&format!(
562 " // Note: Add timing validation (max {} ms)\n",
563 max_duration
564 ));
565 }
566 }
567
568 code.push_str("}\n");
569
570 Ok(code)
571 }
572
573 fn generate_http_file(
575 &self,
576 request: &RecordedRequest,
577 _response: &RecordedResponse,
578 ) -> Result<String> {
579 let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
580 let mut code = String::new();
581
582 code.push_str(&format!("### {} {}\n", request.method, request.path));
583 code.push_str(&format!("{} {}{}\n", request.method, base_url, request.path));
584
585 if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
587 for (key, value) in headers.iter() {
588 if key.to_lowercase() != "host" {
589 code.push_str(&format!("{}: {}\n", key, value));
590 }
591 }
592 }
593
594 if let Some(body) = &request.body {
596 if !body.is_empty() {
597 code.push('\n');
598 code.push_str(body);
599 code.push('\n');
600 }
601 }
602
603 code.push('\n');
604 Ok(code)
605 }
606
607 fn generate_curl(
609 &self,
610 request: &RecordedRequest,
611 _response: &RecordedResponse,
612 ) -> Result<String> {
613 let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
614 let url = format!("{}{}", base_url, request.path);
615
616 let mut code = format!("curl -X {} '{}'", request.method, url);
617
618 if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
620 for (key, value) in headers.iter() {
621 if key.to_lowercase() != "host" {
622 code.push_str(&format!(" \\\n -H '{}: {}'", key, value));
623 }
624 }
625 }
626
627 if let Some(body) = &request.body {
629 if !body.is_empty() {
630 let escaped_body = body.replace('\'', "'\\''");
631 code.push_str(&format!(" \\\n -d '{}'", escaped_body));
632 }
633 }
634
635 Ok(code)
636 }
637
638 fn generate_postman(
640 &self,
641 request: &RecordedRequest,
642 _response: &RecordedResponse,
643 ) -> Result<String> {
644 let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
645
646 let mut headers_vec = Vec::new();
647 if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
648 for (key, value) in headers.iter() {
649 if key.to_lowercase() != "host" {
650 headers_vec.push(serde_json::json!({
651 "key": key,
652 "value": value
653 }));
654 }
655 }
656 }
657
658 let item = serde_json::json!({
659 "name": format!("{} {}", request.method, request.path),
660 "request": {
661 "method": request.method,
662 "header": headers_vec,
663 "url": {
664 "raw": format!("{}{}", base_url, request.path),
665 "protocol": "http",
666 "host": ["localhost"],
667 "port": "3000",
668 "path": request.path.split('/').filter(|s| !s.is_empty()).collect::<Vec<_>>()
669 },
670 "body": if let Some(body) = &request.body {
671 serde_json::json!({
672 "mode": "raw",
673 "raw": body
674 })
675 } else {
676 serde_json::json!({})
677 }
678 }
679 });
680
681 serde_json::to_string_pretty(&item).map_err(RecorderError::Serialization)
682 }
683
684 fn generate_k6(
686 &self,
687 request: &RecordedRequest,
688 response: &RecordedResponse,
689 ) -> Result<String> {
690 let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
691 let url = format!("{}{}", base_url, request.path);
692
693 let mut code = String::new();
694 code.push_str(&format!(" // {} {}\n", request.method, request.path));
695 code.push_str(" {\n");
696
697 let method = request.method.to_lowercase();
698
699 code.push_str(" const params = {\n");
701 code.push_str(" headers: {\n");
702 if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
703 for (key, value) in headers.iter() {
704 if key.to_lowercase() != "host" {
705 code.push_str(&format!(" '{}': '{}',\n", key, value));
706 }
707 }
708 }
709 code.push_str(" },\n");
710 code.push_str(" };\n");
711
712 if let Some(body) = &request.body {
714 if !body.is_empty() {
715 code.push_str(&format!(" const payload = `{}`;\n", body));
716 code.push_str(&format!(
717 " const res = http.{}('{}', payload, params);\n",
718 method, url
719 ));
720 } else {
721 code.push_str(&format!(
722 " const res = http.{}('{}', null, params);\n",
723 method, url
724 ));
725 }
726 } else {
727 code.push_str(&format!(" const res = http.{}('{}', null, params);\n", method, url));
728 }
729
730 if self.config.validate_status {
732 code.push_str(" check(res, {\n");
733 code.push_str(&format!(
734 " 'status is {}': (r) => r.status === {},\n",
735 response.status_code, response.status_code
736 ));
737 code.push_str(" });\n");
738 }
739
740 code.push_str(" }\n");
741 Ok(code)
742 }
743
744 fn generate_python_test(
746 &self,
747 request: &RecordedRequest,
748 response: &RecordedResponse,
749 ) -> Result<String> {
750 let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
751 let url = format!("{}{}", base_url, request.path);
752 let test_name = self.generate_test_name(request);
753
754 let mut code = String::new();
755 code.push_str(&format!("def {}():\n", test_name));
756
757 code.push_str(" headers = {\n");
759 if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
760 for (key, value) in headers.iter() {
761 if key.to_lowercase() != "host" {
762 code.push_str(&format!(" '{}': '{}',\n", key, value));
763 }
764 }
765 }
766 code.push_str(" }\n");
767
768 let method = request.method.to_lowercase();
770 if let Some(body) = &request.body {
771 if !body.is_empty() {
772 code.push_str(&format!(" data = r'''{}'''\n", body));
773 code.push_str(&format!(
774 " response = requests.{}('{}', headers=headers, data=data)\n",
775 method, url
776 ));
777 } else {
778 code.push_str(&format!(
779 " response = requests.{}('{}', headers=headers)\n",
780 method, url
781 ));
782 }
783 } else {
784 code.push_str(&format!(
785 " response = requests.{}('{}', headers=headers)\n",
786 method, url
787 ));
788 }
789
790 if self.config.validate_status {
792 code.push_str(&format!(
793 " assert response.status_code == {}\n",
794 response.status_code
795 ));
796 }
797
798 Ok(code)
799 }
800
801 fn generate_javascript_test(
803 &self,
804 request: &RecordedRequest,
805 response: &RecordedResponse,
806 ) -> Result<String> {
807 let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
808 let url = format!("{}{}", base_url, request.path);
809 let test_name = format!("{} {}", request.method, request.path);
810
811 let mut code = String::new();
812 code.push_str(&format!("test('{}', async () => {{\n", test_name));
813
814 code.push_str(" const options = {\n");
816 code.push_str(&format!(" method: '{}',\n", request.method));
817 code.push_str(" headers: {\n");
818 if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
819 for (key, value) in headers.iter() {
820 if key.to_lowercase() != "host" {
821 code.push_str(&format!(" '{}': '{}',\n", key, value));
822 }
823 }
824 }
825 code.push_str(" },\n");
826
827 if let Some(body) = &request.body {
828 if !body.is_empty() {
829 code.push_str(&format!(" body: `{}`,\n", body));
830 }
831 }
832
833 code.push_str(" };\n");
834
835 code.push_str(&format!(" const response = await fetch('{}', options);\n", url));
836
837 if self.config.validate_status {
838 code.push_str(&format!(" expect(response.status).toBe({});\n", response.status_code));
839 }
840
841 if self.config.validate_body && response.body.is_some() {
842 code.push_str(" const data = await response.json();\n");
843 code.push_str(" expect(data).toBeDefined();\n");
844 }
845
846 code.push_str("});\n");
847 Ok(code)
848 }
849
850 fn generate_go_test(
852 &self,
853 request: &RecordedRequest,
854 response: &RecordedResponse,
855 ) -> Result<String> {
856 let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
857 let url = format!("{}{}", base_url, request.path);
858 let test_name = self
859 .generate_test_name(request)
860 .split('_')
861 .map(|s| {
862 let mut c = s.chars();
863 match c.next() {
864 None => String::new(),
865 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
866 }
867 })
868 .collect::<String>();
869
870 let mut code = String::new();
871 code.push_str(&format!("func {}(t *testing.T) {{\n", test_name));
872
873 if let Some(body) = &request.body {
875 if !body.is_empty() {
876 code.push_str(&format!(" body := strings.NewReader(`{}`)\n", body));
877 code.push_str(&format!(
878 " req, err := http.NewRequest(\"{}\", \"{}\", body)\n",
879 request.method, url
880 ));
881 } else {
882 code.push_str(&format!(
883 " req, err := http.NewRequest(\"{}\", \"{}\", nil)\n",
884 request.method, url
885 ));
886 }
887 } else {
888 code.push_str(&format!(
889 " req, err := http.NewRequest(\"{}\", \"{}\", nil)\n",
890 request.method, url
891 ));
892 }
893
894 code.push_str(" if err != nil {\n");
895 code.push_str(" t.Fatal(err)\n");
896 code.push_str(" }\n");
897
898 if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
900 for (key, value) in headers.iter() {
901 if key.to_lowercase() != "host" {
902 code.push_str(&format!(" req.Header.Set(\"{}\", \"{}\")\n", key, value));
903 }
904 }
905 }
906
907 code.push_str(" client := &http.Client{}\n");
909 code.push_str(" resp, err := client.Do(req)\n");
910 code.push_str(" if err != nil {\n");
911 code.push_str(" t.Fatal(err)\n");
912 code.push_str(" }\n");
913 code.push_str(" defer resp.Body.Close()\n\n");
914
915 if self.config.validate_status {
917 code.push_str(&format!(" if resp.StatusCode != {} {{\n", response.status_code));
918 code.push_str(&format!(
919 " t.Errorf(\"Expected status {}, got %d\", resp.StatusCode)\n",
920 response.status_code
921 ));
922 code.push_str(" }\n");
923 }
924
925 code.push_str("}\n");
926 Ok(code)
927 }
928
929 fn generate_ruby_test(
931 &self,
932 request: &RecordedRequest,
933 response: &RecordedResponse,
934 ) -> Result<String> {
935 let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
936 let url = format!("{}{}", base_url, request.path);
937 let test_name = request
938 .path
939 .trim_start_matches('/')
940 .replace(['/', '-'], " ")
941 .replace("{", "")
942 .replace("}", "");
943
944 let mut code = String::new();
945 code.push_str(&format!(" it \"should {} {}\" do\n", request.method, test_name));
946
947 let mut request_params = vec![format!("method: :{}", request.method.to_lowercase())];
949
950 if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
952 let header_items: Vec<String> = headers
953 .iter()
954 .filter(|(k, _)| k.to_lowercase() != "host")
955 .map(|(k, v)| format!("'{}' => '{}'", k, v))
956 .collect();
957 if !header_items.is_empty() {
958 request_params.push(format!("headers: {{ {} }}", header_items.join(", ")));
959 }
960 }
961
962 if let Some(body) = &request.body {
964 if !body.is_empty() {
965 let escaped_body = body.replace('\'', "\\'").replace('\n', "\\n");
966 request_params.push(format!("body: '{}'", escaped_body));
967 }
968 }
969
970 code.push_str(&format!(
971 " response = HTTParty.{}('{}', {})\n",
972 request.method.to_lowercase(),
973 url,
974 request_params.join(", ")
975 ));
976
977 if self.config.validate_status {
979 code.push_str(&format!(" expect(response.code).to eq({})\n", response.status_code));
980 }
981
982 if self.config.validate_body && response.body.is_some() {
983 if let Some(body) = &response.body {
984 if serde_json::from_str::<Value>(body).is_ok() {
985 code.push_str(" expect(response.parsed_response).not_to be_nil\n");
986 }
987 }
988 }
989
990 code.push_str(" end\n");
991 Ok(code)
992 }
993
994 fn generate_java_test(
996 &self,
997 request: &RecordedRequest,
998 response: &RecordedResponse,
999 ) -> Result<String> {
1000 let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
1001 let url = format!("{}{}", base_url, request.path);
1002 let test_name = self
1003 .generate_test_name(request)
1004 .split('_')
1005 .enumerate()
1006 .map(|(i, s)| {
1007 if i == 0 {
1008 s.to_string()
1009 } else {
1010 let mut c = s.chars();
1011 match c.next() {
1012 None => String::new(),
1013 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
1014 }
1015 }
1016 })
1017 .collect::<String>();
1018
1019 let mut code = String::new();
1020 code.push_str(" @Test\n");
1021 code.push_str(&format!(" public void {}() throws Exception {{\n", test_name));
1022
1023 code.push_str(" HttpRequest request = HttpRequest.newBuilder()\n");
1025 code.push_str(&format!(" .uri(URI.create(\"{}\"))\n", url));
1026 code.push_str(&format!(" .method(\"{}\", ", request.method));
1027
1028 if let Some(body) = &request.body {
1029 if !body.is_empty() {
1030 let escaped_body = body.replace('"', "\\\"").replace('\n', "\\n");
1031 code.push_str(&format!(
1032 "HttpRequest.BodyPublishers.ofString(\"{}\"))\n",
1033 escaped_body
1034 ));
1035 } else {
1036 code.push_str("HttpRequest.BodyPublishers.noBody())\n");
1037 }
1038 } else {
1039 code.push_str("HttpRequest.BodyPublishers.noBody())\n");
1040 }
1041
1042 if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
1044 for (key, value) in headers.iter() {
1045 if key.to_lowercase() != "host" {
1046 code.push_str(&format!(" .header(\"{}\", \"{}\")\n", key, value));
1047 }
1048 }
1049 }
1050
1051 code.push_str(" .build();\n\n");
1052
1053 code.push_str(" HttpClient client = HttpClient.newHttpClient();\n");
1055 code.push_str(" HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());\n\n");
1056
1057 if self.config.validate_status {
1059 code.push_str(&format!(
1060 " assertEquals({}, response.statusCode());\n",
1061 response.status_code
1062 ));
1063 }
1064
1065 if self.config.validate_body && response.body.is_some() {
1066 if let Some(body) = &response.body {
1067 if serde_json::from_str::<Value>(body).is_ok() {
1068 code.push_str(" assertNotNull(response.body());\n");
1069 }
1070 }
1071 }
1072
1073 code.push_str(" }\n");
1074 Ok(code)
1075 }
1076
1077 fn generate_csharp_test(
1079 &self,
1080 request: &RecordedRequest,
1081 response: &RecordedResponse,
1082 ) -> Result<String> {
1083 let base_url = self.config.base_url.as_deref().unwrap_or("http://localhost:3000");
1084 let url = format!("{}{}", base_url, request.path);
1085 let test_name = self
1086 .generate_test_name(request)
1087 .split('_')
1088 .map(|s| {
1089 let mut c = s.chars();
1090 match c.next() {
1091 None => String::new(),
1092 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
1093 }
1094 })
1095 .collect::<String>();
1096
1097 let mut code = String::new();
1098 code.push_str(" [Fact]\n");
1099 code.push_str(&format!(" public async Task {}Async()\n", test_name));
1100 code.push_str(" {\n");
1101
1102 code.push_str(" using var client = new HttpClient();\n");
1104
1105 code.push_str(&format!(
1107 " var request = new HttpRequestMessage(HttpMethod.{}, \"{}\");\n",
1108 request.method.chars().next().unwrap().to_uppercase().collect::<String>()
1109 + &request.method[1..].to_lowercase(),
1110 url
1111 ));
1112
1113 if let Ok(headers) = serde_json::from_str::<HashMap<String, String>>(&request.headers) {
1115 for (key, value) in headers.iter() {
1116 if key.to_lowercase() != "host" && key.to_lowercase() != "content-type" {
1117 code.push_str(&format!(
1118 " request.Headers.Add(\"{}\", \"{}\");\n",
1119 key, value
1120 ));
1121 }
1122 }
1123 }
1124
1125 if let Some(body) = &request.body {
1127 if !body.is_empty() {
1128 let escaped_body = body.replace('"', "\\\"").replace('\n', "\\n");
1129 code.push_str(&format!(" request.Content = new StringContent(\"{}\", Encoding.UTF8, \"application/json\");\n",
1130 escaped_body));
1131 }
1132 }
1133
1134 code.push_str(" var response = await client.SendAsync(request);\n\n");
1136
1137 if self.config.validate_status {
1139 code.push_str(&format!(
1140 " Assert.Equal({}, (int)response.StatusCode);\n",
1141 response.status_code
1142 ));
1143 }
1144
1145 if self.config.validate_body && response.body.is_some() {
1146 code.push_str(
1147 " var content = await response.Content.ReadAsStringAsync();\n",
1148 );
1149 code.push_str(" Assert.NotNull(content);\n");
1150 code.push_str(" Assert.NotEmpty(content);\n");
1151 }
1152
1153 code.push_str(" }\n");
1154 Ok(code)
1155 }
1156
1157 fn generate_test_file(&self, tests: &[GeneratedTest]) -> Result<String> {
1159 let mut file = String::new();
1160
1161 match self.config.format {
1162 TestFormat::RustReqwest => {
1163 file.push_str("// Generated test file\n");
1164 file.push_str("// Run with: cargo test\n\n");
1165 if self.config.include_setup_teardown {
1166 file.push_str("use reqwest;\n");
1167 file.push_str("use serde_json::Value;\n\n");
1168 }
1169
1170 for test in tests {
1171 file.push_str(&test.code);
1172 file.push('\n');
1173 }
1174 }
1175 TestFormat::HttpFile => {
1176 for test in tests {
1177 file.push_str(&test.code);
1178 file.push('\n');
1179 }
1180 }
1181 TestFormat::Curl => {
1182 file.push_str("#!/bin/bash\n");
1183 file.push_str("# Generated cURL commands\n\n");
1184 for test in tests {
1185 file.push_str(&format!("# {} {}\n", test.method, test.endpoint));
1186 file.push_str(&test.code);
1187 file.push_str("\n\n");
1188 }
1189 }
1190 TestFormat::Postman => {
1191 let collection = serde_json::json!({
1192 "info": {
1193 "name": self.config.suite_name,
1194 "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
1195 },
1196 "item": tests.iter().map(|t| {
1197 serde_json::from_str::<Value>(&t.code).unwrap_or(Value::Null)
1198 }).collect::<Vec<_>>()
1199 });
1200 file = serde_json::to_string_pretty(&collection)
1201 .map_err(RecorderError::Serialization)?;
1202 }
1203 TestFormat::K6 => {
1204 file.push_str("import http from 'k6/http';\n");
1205 file.push_str("import { check, sleep } from 'k6';\n\n");
1206 file.push_str("export const options = {\n");
1207 file.push_str(" vus: 10,\n");
1208 file.push_str(" duration: '30s',\n");
1209 file.push_str("};\n\n");
1210 file.push_str("export default function() {\n");
1211 for test in tests {
1212 file.push_str(&test.code);
1213 }
1214 file.push_str(" sleep(1);\n");
1215 file.push_str("}\n");
1216 }
1217 TestFormat::PythonPytest => {
1218 file.push_str("# Generated test file\n");
1219 file.push_str("# Run with: pytest\n\n");
1220 file.push_str("import requests\n");
1221 file.push_str("import pytest\n\n");
1222 for test in tests {
1223 file.push_str(&test.code);
1224 file.push('\n');
1225 }
1226 }
1227 TestFormat::JavaScriptJest => {
1228 file.push_str("// Generated test file\n");
1229 file.push_str("// Run with: npm test\n\n");
1230 file.push_str(&format!("describe('{}', () => {{\n", self.config.suite_name));
1231 for test in tests {
1232 file.push_str(" ");
1233 file.push_str(&test.code.replace("\n", "\n "));
1234 file.push('\n');
1235 }
1236 file.push_str("});\n");
1237 }
1238 TestFormat::GoTest => {
1239 file.push_str("package main\n\n");
1240 file.push_str("import (\n");
1241 file.push_str(" \"net/http\"\n");
1242 file.push_str(" \"strings\"\n");
1243 file.push_str(" \"testing\"\n");
1244 file.push_str(")\n\n");
1245 for test in tests {
1246 file.push_str(&test.code);
1247 file.push('\n');
1248 }
1249 }
1250 TestFormat::RubyRspec => {
1251 file.push_str("# Generated test file\n");
1252 file.push_str("# Run with: rspec spec/api_spec.rb\n\n");
1253 file.push_str("require 'httparty'\n");
1254 file.push_str("require 'rspec'\n\n");
1255 file.push_str(&format!("RSpec.describe '{}' do\n", self.config.suite_name));
1256 for test in tests {
1257 file.push_str(&test.code);
1258 file.push('\n');
1259 }
1260 file.push_str("end\n");
1261 }
1262 TestFormat::JavaJunit => {
1263 file.push_str("// Generated test file\n");
1264 file.push_str("// Run with: mvn test or gradle test\n\n");
1265 file.push_str("import org.junit.jupiter.api.Test;\n");
1266 file.push_str("import static org.junit.jupiter.api.Assertions.*;\n");
1267 file.push_str("import java.net.URI;\n");
1268 file.push_str("import java.net.http.HttpClient;\n");
1269 file.push_str("import java.net.http.HttpRequest;\n");
1270 file.push_str("import java.net.http.HttpResponse;\n\n");
1271 file.push_str(&format!(
1272 "public class {} {{\n",
1273 self.config.suite_name.replace("-", "_")
1274 ));
1275 for test in tests {
1276 file.push_str(&test.code);
1277 file.push('\n');
1278 }
1279 file.push_str("}\n");
1280 }
1281 TestFormat::CSharpXunit => {
1282 file.push_str("// Generated test file\n");
1283 file.push_str("// Run with: dotnet test\n\n");
1284 file.push_str("using System;\n");
1285 file.push_str("using System.Net.Http;\n");
1286 file.push_str("using System.Text;\n");
1287 file.push_str("using System.Threading.Tasks;\n");
1288 file.push_str("using Xunit;\n\n");
1289 file.push_str(&format!("namespace {}\n", self.config.suite_name.replace("-", "_")));
1290 file.push_str("{\n");
1291 file.push_str(" public class ApiTests\n");
1292 file.push_str(" {\n");
1293 for test in tests {
1294 file.push_str(&test.code);
1295 file.push('\n');
1296 }
1297 file.push_str(" }\n");
1298 file.push_str("}\n");
1299 }
1300 }
1301
1302 Ok(file)
1303 }
1304
1305 fn deduplicate_tests(&self, tests: Vec<GeneratedTest>) -> Vec<GeneratedTest> {
1307 let mut unique_tests = Vec::new();
1308 let mut seen_signatures = std::collections::HashSet::new();
1309
1310 for test in tests {
1311 let signature = format!("{}:{}:{}", test.method, test.endpoint, test.code.len());
1313
1314 if !seen_signatures.contains(&signature) {
1315 seen_signatures.insert(signature);
1316 unique_tests.push(test);
1317 }
1318 }
1319
1320 unique_tests
1321 }
1322
1323 fn optimize_test_order(&self, mut tests: Vec<GeneratedTest>) -> Vec<GeneratedTest> {
1325 tests.sort_by(|a, b| {
1330 let order_a = match a.method.as_str() {
1331 "GET" | "HEAD" => 0,
1332 "POST" | "PUT" | "PATCH" => 1,
1333 "DELETE" => 2,
1334 _ => 3,
1335 };
1336 let order_b = match b.method.as_str() {
1337 "GET" | "HEAD" => 0,
1338 "POST" | "PUT" | "PATCH" => 1,
1339 "DELETE" => 2,
1340 _ => 3,
1341 };
1342 order_a.cmp(&order_b).then_with(|| a.endpoint.cmp(&b.endpoint))
1343 });
1344
1345 tests
1346 }
1347
1348 async fn generate_test_fixtures(
1350 &self,
1351 exchanges: &[crate::models::RecordedExchange],
1352 ) -> Result<Vec<TestFixture>> {
1353 if self.config.llm_config.is_none() {
1354 return Ok(Vec::new());
1355 }
1356
1357 let llm_config = self.config.llm_config.as_ref().unwrap();
1358 let mut fixtures = Vec::new();
1359
1360 let mut endpoint_data: HashMap<String, Vec<&crate::models::RecordedExchange>> =
1362 HashMap::new();
1363 for exchange in exchanges {
1364 let endpoint = format!("{} {}", exchange.request.method, exchange.request.path);
1365 endpoint_data.entry(endpoint).or_default().push(exchange);
1366 }
1367
1368 for (endpoint, endpoint_exchanges) in endpoint_data.iter().take(5) {
1370 let mut sample_bodies = Vec::new();
1372 for exchange in endpoint_exchanges.iter().take(3) {
1373 if let Some(body) = &exchange.request.body {
1374 if !body.is_empty() {
1375 if let Ok(json) = serde_json::from_str::<Value>(body) {
1376 sample_bodies.push(json);
1377 }
1378 }
1379 }
1380 }
1381
1382 if sample_bodies.is_empty() {
1383 continue;
1384 }
1385
1386 let prompt = format!(
1387 "Based on these sample API request bodies for endpoint '{}', generate a reusable test fixture in JSON format:\n{}\n\nProvide a clean JSON object with varied test data including edge cases.",
1388 endpoint,
1389 serde_json::to_string_pretty(&sample_bodies).unwrap_or_default()
1390 );
1391
1392 if let Ok(response) = self.call_llm(llm_config, &prompt).await {
1393 if let Ok(data) = serde_json::from_str::<Value>(&response) {
1395 fixtures.push(TestFixture {
1396 name: format!("fixture_{}", endpoint.replace([' ', '/'], "_")),
1397 description: format!("Test fixture for {}", endpoint),
1398 data,
1399 endpoints: vec![endpoint.clone()],
1400 });
1401 }
1402 }
1403 }
1404
1405 Ok(fixtures)
1406 }
1407
1408 async fn suggest_edge_cases(
1410 &self,
1411 exchanges: &[crate::models::RecordedExchange],
1412 ) -> Result<Vec<EdgeCaseSuggestion>> {
1413 if self.config.llm_config.is_none() {
1414 return Ok(Vec::new());
1415 }
1416
1417 let llm_config = self.config.llm_config.as_ref().unwrap();
1418 let mut edge_cases = Vec::new();
1419
1420 let mut endpoint_data: HashMap<String, Vec<&crate::models::RecordedExchange>> =
1422 HashMap::new();
1423 for exchange in exchanges {
1424 let key = format!("{} {}", exchange.request.method, exchange.request.path);
1425 endpoint_data.entry(key).or_default().push(exchange);
1426 }
1427
1428 for (endpoint_key, endpoint_exchanges) in endpoint_data.iter().take(5) {
1429 let parts: Vec<&str> = endpoint_key.splitn(2, ' ').collect();
1430 if parts.len() != 2 {
1431 continue;
1432 }
1433 let (method, endpoint) = (parts[0], parts[1]);
1434
1435 let sample_exchange = endpoint_exchanges.first();
1437 let sample_body = sample_exchange
1438 .and_then(|e| e.request.body.as_ref())
1439 .map(|s| s.as_str())
1440 .unwrap_or("{}");
1441
1442 let prompt = format!(
1443 "Suggest 3 critical edge cases to test for this API endpoint:\n\
1444 Method: {}\n\
1445 Path: {}\n\
1446 Sample Request: {}\n\n\
1447 For each edge case, provide:\n\
1448 1. Type (e.g., 'validation', 'boundary', 'security')\n\
1449 2. Description\n\
1450 3. Expected behavior\n\
1451 4. Priority (1-5)\n\n\
1452 Format: type|description|behavior|priority",
1453 method, endpoint, sample_body
1454 );
1455
1456 if let Ok(response) = self.call_llm(llm_config, &prompt).await {
1457 for line in response.lines().take(3) {
1459 let parts: Vec<&str> = line.split('|').collect();
1460 if parts.len() >= 4 {
1461 let priority = parts[3].trim().parse::<u8>().unwrap_or(3);
1462 edge_cases.push(EdgeCaseSuggestion {
1463 endpoint: endpoint.to_string(),
1464 method: method.to_string(),
1465 case_type: parts[0].trim().to_string(),
1466 description: parts[1].trim().to_string(),
1467 suggested_input: None,
1468 expected_behavior: parts[2].trim().to_string(),
1469 priority,
1470 });
1471 }
1472 }
1473 }
1474 }
1475
1476 Ok(edge_cases)
1477 }
1478
1479 async fn analyze_test_gaps(
1481 &self,
1482 exchanges: &[crate::models::RecordedExchange],
1483 tests: &[GeneratedTest],
1484 ) -> Result<TestGapAnalysis> {
1485 let mut all_endpoints = std::collections::HashSet::new();
1487 let mut method_by_endpoint: HashMap<String, std::collections::HashSet<String>> =
1488 HashMap::new();
1489 let mut status_codes_by_endpoint: HashMap<String, std::collections::HashSet<u16>> =
1490 HashMap::new();
1491
1492 for exchange in exchanges {
1493 let endpoint = exchange.request.path.clone();
1494 let method = exchange.request.method.clone();
1495 all_endpoints.insert(endpoint.clone());
1496 method_by_endpoint.entry(endpoint.clone()).or_default().insert(method);
1497
1498 if let Some(response) = &exchange.response {
1499 let status_code = response.status_code as u16;
1500 status_codes_by_endpoint
1501 .entry(endpoint.clone())
1502 .or_default()
1503 .insert(status_code);
1504 }
1505 }
1506
1507 let mut tested_endpoints = std::collections::HashSet::new();
1509 for test in tests {
1510 tested_endpoints.insert(test.endpoint.clone());
1511 }
1512
1513 let untested_endpoints: Vec<String> =
1515 all_endpoints.difference(&tested_endpoints).cloned().collect();
1516
1517 let mut missing_methods: HashMap<String, Vec<String>> = HashMap::new();
1518 for (endpoint, methods) in &method_by_endpoint {
1519 let tested_methods: std::collections::HashSet<String> = tests
1520 .iter()
1521 .filter(|t| &t.endpoint == endpoint)
1522 .map(|t| t.method.clone())
1523 .collect();
1524
1525 let missing: Vec<String> = methods.difference(&tested_methods).cloned().collect();
1526
1527 if !missing.is_empty() {
1528 missing_methods.insert(endpoint.clone(), missing);
1529 }
1530 }
1531
1532 let mut missing_status_codes: HashMap<String, Vec<u16>> = HashMap::new();
1533 for (endpoint, codes) in &status_codes_by_endpoint {
1534 let has_error_tests = codes.iter().any(|c| *c >= 400);
1536 if has_error_tests {
1537 missing_status_codes.insert(
1538 endpoint.clone(),
1539 codes.iter().filter(|c| **c >= 400).copied().collect(),
1540 );
1541 }
1542 }
1543
1544 let missing_error_scenarios = vec![
1545 "401 Unauthorized scenarios".to_string(),
1546 "403 Forbidden scenarios".to_string(),
1547 "404 Not Found scenarios".to_string(),
1548 "429 Rate Limiting scenarios".to_string(),
1549 "500 Internal Server Error scenarios".to_string(),
1550 ];
1551
1552 let coverage_percentage = if all_endpoints.is_empty() {
1553 100.0
1554 } else {
1555 (tested_endpoints.len() as f64 / all_endpoints.len() as f64) * 100.0
1556 };
1557
1558 let mut recommendations = Vec::new();
1559 if !untested_endpoints.is_empty() {
1560 recommendations
1561 .push(format!("Add tests for {} untested endpoints", untested_endpoints.len()));
1562 }
1563 if !missing_methods.is_empty() {
1564 recommendations.push(format!(
1565 "Add tests for missing HTTP methods on {} endpoints",
1566 missing_methods.len()
1567 ));
1568 }
1569 if coverage_percentage < 80.0 {
1570 recommendations.push("Increase test coverage to at least 80%".to_string());
1571 }
1572
1573 Ok(TestGapAnalysis {
1574 untested_endpoints,
1575 missing_methods,
1576 missing_status_codes,
1577 missing_error_scenarios,
1578 coverage_percentage,
1579 recommendations,
1580 })
1581 }
1582}
1583
1584#[cfg(test)]
1585mod tests {
1586 use super::*;
1587 use crate::models::{Protocol, RecordedRequest, RecordedResponse};
1588
1589 #[tokio::test]
1590 async fn test_generate_test_name() {
1591 let database = RecorderDatabase::new_in_memory().await.unwrap();
1592 let config = TestGenerationConfig::default();
1593 let generator = TestGenerator::new(database, config);
1594
1595 let request = RecordedRequest {
1596 id: "test".to_string(),
1597 protocol: Protocol::Http,
1598 timestamp: chrono::Utc::now(),
1599 method: "GET".to_string(),
1600 path: "/api/users/123".to_string(),
1601 query_params: None,
1602 headers: "{}".to_string(),
1603 body: None,
1604 body_encoding: "utf-8".to_string(),
1605 status_code: Some(200),
1606 duration_ms: Some(50),
1607 client_ip: None,
1608 trace_id: None,
1609 span_id: None,
1610 tags: None,
1611 };
1612
1613 let name = generator.generate_test_name(&request);
1614 assert_eq!(name, "test_get_api_users_123");
1615 }
1616
1617 #[test]
1618 fn test_default_config() {
1619 let config = TestGenerationConfig::default();
1620 assert_eq!(config.format, TestFormat::RustReqwest);
1621 assert!(config.include_assertions);
1622 assert!(config.validate_body);
1623 assert!(config.validate_status);
1624 }
1625
1626 #[tokio::test]
1627 async fn test_generate_rust_test() {
1628 let database = RecorderDatabase::new_in_memory().await.unwrap();
1629 let config = TestGenerationConfig::default();
1630 let generator = TestGenerator::new(database, config);
1631
1632 let request = RecordedRequest {
1633 id: "test-1".to_string(),
1634 protocol: Protocol::Http,
1635 timestamp: chrono::Utc::now(),
1636 method: "GET".to_string(),
1637 path: "/api/users".to_string(),
1638 query_params: None,
1639 headers: r#"{"content-type":"application/json"}"#.to_string(),
1640 body: None,
1641 body_encoding: "utf-8".to_string(),
1642 status_code: Some(200),
1643 duration_ms: Some(45),
1644 client_ip: Some("127.0.0.1".to_string()),
1645 trace_id: None,
1646 span_id: None,
1647 tags: None,
1648 };
1649
1650 let response = RecordedResponse {
1651 request_id: "test-1".to_string(),
1652 status_code: 200,
1653 headers: r#"{"content-type":"application/json"}"#.to_string(),
1654 body: Some(r#"{"users":[]}"#.to_string()),
1655 body_encoding: "utf-8".to_string(),
1656 size_bytes: 12,
1657 timestamp: chrono::Utc::now(),
1658 };
1659
1660 let code = generator.generate_rust_test(&request, &response).unwrap();
1661
1662 assert!(code.contains("#[tokio::test]"));
1663 assert!(code.contains("async fn test_get_api_users()"));
1664 assert!(code.contains("reqwest::Client::new()"));
1665 assert!(code.contains("assert_eq!(response.status().as_u16(), 200)"));
1666 }
1667
1668 #[tokio::test]
1669 async fn test_generate_curl() {
1670 let database = RecorderDatabase::new_in_memory().await.unwrap();
1671 let config = TestGenerationConfig::default();
1672 let generator = TestGenerator::new(database, config);
1673
1674 let request = RecordedRequest {
1675 id: "test-2".to_string(),
1676 protocol: Protocol::Http,
1677 timestamp: chrono::Utc::now(),
1678 method: "POST".to_string(),
1679 path: "/api/users".to_string(),
1680 query_params: None,
1681 headers: r#"{"content-type":"application/json"}"#.to_string(),
1682 body: Some(r#"{"name":"John"}"#.to_string()),
1683 body_encoding: "utf-8".to_string(),
1684 status_code: Some(201),
1685 duration_ms: Some(80),
1686 client_ip: None,
1687 trace_id: None,
1688 span_id: None,
1689 tags: None,
1690 };
1691
1692 let response = RecordedResponse {
1693 request_id: "test-2".to_string(),
1694 status_code: 201,
1695 headers: r#"{}"#.to_string(),
1696 body: None,
1697 body_encoding: "utf-8".to_string(),
1698 size_bytes: 0,
1699 timestamp: chrono::Utc::now(),
1700 };
1701
1702 let code = generator.generate_curl(&request, &response).unwrap();
1703
1704 assert!(code.contains("curl -X POST"));
1705 assert!(code.contains("/api/users"));
1706 assert!(code.contains("-H 'content-type: application/json'"));
1707 assert!(code.contains(r#"-d '{"name":"John"}'"#));
1708 }
1709
1710 #[tokio::test]
1711 async fn test_generate_http_file() {
1712 let database = RecorderDatabase::new_in_memory().await.unwrap();
1713 let config = TestGenerationConfig::default();
1714 let generator = TestGenerator::new(database, config);
1715
1716 let request = RecordedRequest {
1717 id: "test-3".to_string(),
1718 protocol: Protocol::Http,
1719 timestamp: chrono::Utc::now(),
1720 method: "DELETE".to_string(),
1721 path: "/api/users/123".to_string(),
1722 query_params: None,
1723 headers: r#"{}"#.to_string(),
1724 body: None,
1725 body_encoding: "utf-8".to_string(),
1726 status_code: Some(204),
1727 duration_ms: Some(30),
1728 client_ip: None,
1729 trace_id: None,
1730 span_id: None,
1731 tags: None,
1732 };
1733
1734 let response = RecordedResponse {
1735 request_id: "test-3".to_string(),
1736 status_code: 204,
1737 headers: r#"{}"#.to_string(),
1738 body: None,
1739 body_encoding: "utf-8".to_string(),
1740 size_bytes: 0,
1741 timestamp: chrono::Utc::now(),
1742 };
1743
1744 let code = generator.generate_http_file(&request, &response).unwrap();
1745
1746 assert!(code.contains("### DELETE /api/users/123"));
1747 assert!(code.contains("DELETE http://localhost:3000/api/users/123"));
1748 }
1749
1750 #[test]
1751 fn test_llm_config_defaults() {
1752 let config = LlmConfig::default();
1753 assert_eq!(config.provider, "ollama");
1754 assert_eq!(config.model, "llama2");
1755 assert_eq!(config.temperature, 0.3);
1756 }
1757
1758 #[test]
1759 fn test_test_format_variants() {
1760 assert_eq!(TestFormat::RustReqwest, TestFormat::RustReqwest);
1761 assert_ne!(TestFormat::RustReqwest, TestFormat::Curl);
1762 assert_ne!(TestFormat::PythonPytest, TestFormat::JavaScriptJest);
1763 }
1764}