mockforge_recorder/
integration_testing.rs

1//! Integration testing workflow engine
2//!
3//! Supports multi-endpoint test flows with state management, variable extraction,
4//! and conditional logic for comprehensive integration testing.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashMap;
10
11/// Integration test workflow
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct IntegrationWorkflow {
14    /// Workflow ID
15    pub id: String,
16    /// Workflow name
17    pub name: String,
18    /// Description
19    pub description: String,
20    /// Steps in the workflow
21    pub steps: Vec<WorkflowStep>,
22    /// Global setup (variables, config)
23    pub setup: WorkflowSetup,
24    /// Cleanup steps
25    pub cleanup: Vec<WorkflowStep>,
26    /// Created timestamp
27    pub created_at: DateTime<Utc>,
28}
29
30/// Workflow setup configuration
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct WorkflowSetup {
33    /// Initial variables
34    pub variables: HashMap<String, String>,
35    /// Base URL
36    pub base_url: String,
37    /// Global headers
38    pub headers: HashMap<String, String>,
39    /// Timeout in milliseconds
40    pub timeout_ms: u64,
41}
42
43impl Default for WorkflowSetup {
44    fn default() -> Self {
45        Self {
46            variables: HashMap::new(),
47            base_url: "http://localhost:3000".to_string(),
48            headers: HashMap::new(),
49            timeout_ms: 30000,
50        }
51    }
52}
53
54/// Workflow step
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct WorkflowStep {
57    /// Step ID
58    pub id: String,
59    /// Step name
60    pub name: String,
61    /// Description
62    pub description: String,
63    /// HTTP request to execute
64    pub request: StepRequest,
65    /// Expected response validation
66    pub validation: StepValidation,
67    /// Variables to extract from response
68    pub extract: Vec<VariableExtraction>,
69    /// Conditional execution
70    pub condition: Option<StepCondition>,
71    /// Delay after step (ms)
72    pub delay_ms: Option<u64>,
73}
74
75/// Step HTTP request
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct StepRequest {
78    /// HTTP method
79    pub method: String,
80    /// Endpoint path (supports variable substitution)
81    pub path: String,
82    /// Headers (supports variable substitution)
83    pub headers: HashMap<String, String>,
84    /// Request body (supports variable substitution)
85    pub body: Option<String>,
86    /// Query parameters
87    pub query_params: HashMap<String, String>,
88}
89
90/// Step validation
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct StepValidation {
93    /// Expected status code
94    pub status_code: Option<u16>,
95    /// Expected response body assertions
96    pub body_assertions: Vec<BodyAssertion>,
97    /// Header assertions
98    pub header_assertions: Vec<HeaderAssertion>,
99    /// Response time assertion (max ms)
100    pub max_response_time_ms: Option<u64>,
101}
102
103/// Body assertion
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct BodyAssertion {
106    /// JSON path or regex
107    pub path: String,
108    /// Assertion type
109    pub assertion_type: AssertionType,
110    /// Expected value
111    pub expected: Value,
112}
113
114/// Assertion type
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
116#[serde(rename_all = "snake_case")]
117pub enum AssertionType {
118    /// Equals
119    Equals,
120    /// Not equals
121    NotEquals,
122    /// Contains
123    Contains,
124    /// Matches regex
125    Matches,
126    /// Greater than
127    GreaterThan,
128    /// Less than
129    LessThan,
130    /// Exists (field is present)
131    Exists,
132    /// Not null
133    NotNull,
134}
135
136/// Header assertion
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct HeaderAssertion {
139    /// Header name
140    pub name: String,
141    /// Expected value or pattern
142    pub expected: String,
143    /// Use regex matching
144    pub regex: bool,
145}
146
147/// Variable extraction
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct VariableExtraction {
150    /// Variable name
151    pub name: String,
152    /// Extraction source
153    pub source: ExtractionSource,
154    /// JSONPath or regex pattern
155    pub pattern: String,
156    /// Default value if extraction fails
157    pub default: Option<String>,
158}
159
160/// Extraction source
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
162#[serde(rename_all = "snake_case")]
163pub enum ExtractionSource {
164    /// Response body
165    Body,
166    /// Response header
167    Header,
168    /// Status code
169    StatusCode,
170}
171
172/// Step condition
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct StepCondition {
175    /// Variable to check
176    pub variable: String,
177    /// Condition operator
178    pub operator: ConditionOperator,
179    /// Value to compare
180    pub value: String,
181}
182
183/// Condition operator
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
185#[serde(rename_all = "snake_case")]
186pub enum ConditionOperator {
187    /// Equals
188    Equals,
189    /// Not equals
190    NotEquals,
191    /// Contains
192    Contains,
193    /// Exists (variable is set)
194    Exists,
195    /// Greater than
196    GreaterThan,
197    /// Less than
198    LessThan,
199}
200
201/// Workflow execution state
202#[derive(Debug, Clone)]
203pub struct WorkflowState {
204    /// Current variables
205    pub variables: HashMap<String, String>,
206    /// Step execution history
207    pub history: Vec<StepExecution>,
208    /// Current step index
209    pub current_step: usize,
210}
211
212/// Step execution record
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct StepExecution {
215    /// Step ID
216    pub step_id: String,
217    /// Step name
218    pub step_name: String,
219    /// Executed timestamp
220    pub executed_at: DateTime<Utc>,
221    /// Request sent
222    pub request: ExecutedRequest,
223    /// Response received
224    pub response: ExecutedResponse,
225    /// Validation result
226    pub validation_result: ValidationResult,
227    /// Variables extracted
228    pub extracted_variables: HashMap<String, String>,
229    /// Duration in milliseconds
230    pub duration_ms: u64,
231}
232
233/// Executed request
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct ExecutedRequest {
236    /// Method
237    pub method: String,
238    /// Full URL
239    pub url: String,
240    /// Headers
241    pub headers: HashMap<String, String>,
242    /// Body
243    pub body: Option<String>,
244}
245
246/// Executed response
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct ExecutedResponse {
249    /// Status code
250    pub status_code: u16,
251    /// Headers
252    pub headers: HashMap<String, String>,
253    /// Body
254    pub body: String,
255}
256
257/// Validation result
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct ValidationResult {
260    /// Overall success
261    pub success: bool,
262    /// Individual assertion results
263    pub assertions: Vec<AssertionResult>,
264    /// Error messages
265    pub errors: Vec<String>,
266}
267
268/// Assertion result
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct AssertionResult {
271    /// Assertion description
272    pub description: String,
273    /// Success
274    pub success: bool,
275    /// Expected value
276    pub expected: String,
277    /// Actual value
278    pub actual: String,
279}
280
281/// Workflow generator - generates integration test code
282pub struct IntegrationTestGenerator {
283    /// Workflow to generate tests from
284    workflow: IntegrationWorkflow,
285}
286
287impl IntegrationTestGenerator {
288    /// Create new generator
289    pub fn new(workflow: IntegrationWorkflow) -> Self {
290        Self { workflow }
291    }
292
293    /// Generate Rust integration test
294    pub fn generate_rust_test(&self) -> String {
295        let mut code = String::new();
296
297        // Imports
298        code.push_str("use reqwest;\n");
299        code.push_str("use serde_json::{json, Value};\n");
300        code.push_str("use std::collections::HashMap;\n\n");
301
302        // Test function
303        code.push_str("#[tokio::test]\n");
304        code.push_str(&format!("async fn test_{}() {{\n", self.sanitize_name(&self.workflow.name)));
305
306        // Setup
307        code.push_str("    let client = reqwest::Client::new();\n");
308        code.push_str(&format!("    let base_url = \"{}\";\n", self.workflow.setup.base_url));
309
310        // Variables
311        code.push_str("    let mut variables: HashMap<String, String> = HashMap::new();\n");
312        for (key, value) in &self.workflow.setup.variables {
313            code.push_str(&format!(
314                "    variables.insert(\"{}\".to_string(), \"{}\".to_string());\n",
315                key, value
316            ));
317        }
318        code.push('\n');
319
320        // Steps
321        for (idx, step) in self.workflow.steps.iter().enumerate() {
322            code.push_str(&format!("    // Step {}: {}\n", idx + 1, step.name));
323
324            // Condition check
325            if let Some(condition) = &step.condition {
326                code.push_str(&self.generate_condition_check(condition));
327            }
328
329            // Build URL
330            code.push_str(&format!(
331                "    let url_{} = format!(\"{{}}{}\"",
332                idx,
333                self.replace_vars(&step.request.path)
334            ));
335            code.push_str(", base_url");
336            // Add variable substitutions
337            for var in self.extract_variables(&step.request.path) {
338                code.push_str(&format!(", variables.get(\"{}\").unwrap_or(&String::new())", var));
339            }
340            code.push_str(");\n");
341
342            // Build request
343            code.push_str(&format!(
344                "    let mut request_{} = client.{}(&url_{})",
345                idx,
346                step.request.method.to_lowercase(),
347                idx
348            ));
349
350            // Add headers
351            if !step.request.headers.is_empty() {
352                code.push('\n');
353                for (key, value) in &step.request.headers {
354                    let value_with_vars = self.replace_vars(value);
355                    code.push_str(&format!("        .header(\"{}\", {})\n", key, value_with_vars));
356                }
357            }
358
359            // Add body
360            if let Some(body) = &step.request.body {
361                let body_with_vars = self.replace_vars(body);
362                code.push_str(&format!("        .body({})\n", body_with_vars));
363            }
364
365            code.push_str(";\n\n");
366
367            // Send request
368            code.push_str(&format!(
369                "    let response_{} = request_{}.send().await.expect(\"Request failed\");\n",
370                idx, idx
371            ));
372
373            // Validation
374            if let Some(status) = step.validation.status_code {
375                code.push_str(&format!(
376                    "    assert_eq!(response_{}.status().as_u16(), {});\n",
377                    idx, status
378                ));
379            }
380
381            // Extract variables
382            if !step.extract.is_empty() {
383                code.push_str(&format!(
384                    "    let body_{} = response_{}.text().await.expect(\"Failed to read body\");\n",
385                    idx, idx
386                ));
387                code.push_str(&format!("    let json_{}: Value = serde_json::from_str(&body_{}).expect(\"Invalid JSON\");\n", idx, idx));
388
389                for extraction in &step.extract {
390                    if extraction.source == ExtractionSource::Body {
391                        code.push_str(&format!("    variables.insert(\"{}\".to_string(), json_{}[\"{}\"].as_str().unwrap_or(\"{}\").to_string());\n",
392                            extraction.name, idx, extraction.pattern, extraction.default.as_deref().unwrap_or("")));
393                    }
394                }
395            }
396
397            // Delay
398            if let Some(delay) = step.delay_ms {
399                code.push_str(&format!(
400                    "    tokio::time::sleep(tokio::time::Duration::from_millis({})).await;\n",
401                    delay
402                ));
403            }
404
405            code.push('\n');
406        }
407
408        code.push_str("}\n");
409        code
410    }
411
412    /// Generate Python integration test
413    pub fn generate_python_test(&self) -> String {
414        let mut code = String::new();
415
416        // Imports
417        code.push_str("import requests\n");
418        code.push_str("import time\n");
419        code.push_str("import pytest\n\n");
420
421        // Test function
422        code.push_str(&format!("def test_{}():\n", self.sanitize_name(&self.workflow.name)));
423
424        // Setup
425        code.push_str(&format!("    base_url = '{}'\n", self.workflow.setup.base_url));
426        code.push_str("    variables = {}\n");
427        for (key, value) in &self.workflow.setup.variables {
428            code.push_str(&format!("    variables['{}'] = '{}'\n", key, value));
429        }
430        code.push('\n');
431
432        // Steps
433        for (idx, step) in self.workflow.steps.iter().enumerate() {
434            code.push_str(&format!("    # Step {}: {}\n", idx + 1, step.name));
435
436            // Build URL
437            let path = self.replace_vars_python(&step.request.path);
438            code.push_str(&format!("    url = f'{{base_url}}{}'\n", path));
439
440            // Build request
441            let method = step.request.method.to_lowercase();
442            code.push_str(&format!("    response = requests.{}(url", method));
443
444            // Add headers
445            if !step.request.headers.is_empty() {
446                code.push_str(", headers={");
447                let headers: Vec<String> = step
448                    .request
449                    .headers
450                    .iter()
451                    .map(|(k, v)| format!("'{}': '{}'", k, self.replace_vars_python(v)))
452                    .collect();
453                code.push_str(&headers.join(", "));
454                code.push('}');
455            }
456
457            // Add body
458            if let Some(body) = &step.request.body {
459                code.push_str(&format!(", json={}", self.replace_vars_python(body)));
460            }
461
462            code.push_str(")\n");
463
464            // Validation
465            if let Some(status) = step.validation.status_code {
466                code.push_str(&format!("    assert response.status_code == {}\n", status));
467            }
468
469            // Extract variables
470            for extraction in &step.extract {
471                if extraction.source == ExtractionSource::Body {
472                    code.push_str(&format!(
473                        "    variables['{}'] = response.json().get('{}', '{}')\n",
474                        extraction.name,
475                        extraction.pattern,
476                        extraction.default.as_deref().unwrap_or("")
477                    ));
478                }
479            }
480
481            // Delay
482            if let Some(delay) = step.delay_ms {
483                code.push_str(&format!("    time.sleep({:.2})\n", delay as f64 / 1000.0));
484            }
485
486            code.push('\n');
487        }
488
489        code
490    }
491
492    /// Generate JavaScript integration test
493    pub fn generate_javascript_test(&self) -> String {
494        let mut code = String::new();
495
496        // Test describe block
497        code.push_str(&format!("describe('{}', () => {{\n", self.workflow.name));
498        code.push_str(&format!("  it('{}', async () => {{\n", self.workflow.description));
499
500        // Setup
501        code.push_str(&format!("    const baseUrl = '{}';\n", self.workflow.setup.base_url));
502        code.push_str("    const variables = {};\n");
503        for (key, value) in &self.workflow.setup.variables {
504            code.push_str(&format!("    variables['{}'] = '{}';\n", key, value));
505        }
506        code.push('\n');
507
508        // Steps
509        for (idx, step) in self.workflow.steps.iter().enumerate() {
510            code.push_str(&format!("    // Step {}: {}\n", idx + 1, step.name));
511
512            // Build URL
513            let path = self.replace_vars_js(&step.request.path);
514            code.push_str(&format!("    const url{} = `${{baseUrl}}{}`;\n", idx, path));
515
516            // Build request
517            code.push_str(&format!("    const response{} = await fetch(url{}, {{\n", idx, idx));
518            code.push_str(&format!("      method: '{}',\n", step.request.method.to_uppercase()));
519
520            // Add headers
521            if !step.request.headers.is_empty() {
522                code.push_str("      headers: {\n");
523                for (key, value) in &step.request.headers {
524                    code.push_str(&format!(
525                        "        '{}': '{}',\n",
526                        key,
527                        self.replace_vars_js(value)
528                    ));
529                }
530                code.push_str("      },\n");
531            }
532
533            // Add body
534            if let Some(body) = &step.request.body {
535                code.push_str(&format!(
536                    "      body: JSON.stringify({}),\n",
537                    self.replace_vars_js(body)
538                ));
539            }
540
541            code.push_str("    });\n");
542
543            // Validation
544            if let Some(status) = step.validation.status_code {
545                code.push_str(&format!("    expect(response{}.status).toBe({});\n", idx, status));
546            }
547
548            // Extract variables
549            if !step.extract.is_empty() {
550                code.push_str(&format!("    const data{} = await response{}.json();\n", idx, idx));
551                for extraction in &step.extract {
552                    if extraction.source == ExtractionSource::Body {
553                        code.push_str(&format!(
554                            "    variables['{}'] = data{}.{} || '{}';\n",
555                            extraction.name,
556                            idx,
557                            extraction.pattern,
558                            extraction.default.as_deref().unwrap_or("")
559                        ));
560                    }
561                }
562            }
563
564            // Delay
565            if let Some(delay) = step.delay_ms {
566                code.push_str(&format!(
567                    "    await new Promise(resolve => setTimeout(resolve, {}));\n",
568                    delay
569                ));
570            }
571
572            code.push('\n');
573        }
574
575        code.push_str("  });\n");
576        code.push_str("});\n");
577        code
578    }
579
580    // Helper methods
581    fn sanitize_name(&self, name: &str) -> String {
582        name.to_lowercase()
583            .replace([' ', '-'], "_")
584            .chars()
585            .filter(|c| c.is_alphanumeric() || *c == '_')
586            .collect()
587    }
588
589    fn extract_variables(&self, text: &str) -> Vec<String> {
590        let mut vars = Vec::new();
591        let mut in_var = false;
592        let mut current_var = String::new();
593
594        for c in text.chars() {
595            if c == '{' {
596                in_var = true;
597            } else if c == '}' && in_var {
598                if !current_var.is_empty() {
599                    vars.push(current_var.clone());
600                    current_var.clear();
601                }
602                in_var = false;
603            } else if in_var {
604                current_var.push(c);
605            }
606        }
607
608        vars
609    }
610
611    fn replace_vars(&self, text: &str) -> String {
612        let vars = self.extract_variables(text);
613        if vars.is_empty() {
614            return format!("\"{}\"", text);
615        }
616
617        let mut result = text.to_string();
618        for var in vars {
619            result = result.replace(&format!("{{{}}}", var), "{}");
620        }
621        format!("\"{}\"", result)
622    }
623
624    fn replace_vars_python(&self, text: &str) -> String {
625        let mut result = text.to_string();
626        for var in self.extract_variables(text) {
627            result = result.replace(&format!("{{{}}}", var), &format!("{{variables['{}']}}", var));
628        }
629        result
630    }
631
632    fn replace_vars_js(&self, text: &str) -> String {
633        let mut result = text.to_string();
634        for var in self.extract_variables(text) {
635            result = result.replace(&format!("{{{}}}", var), &format!("${{variables['{}']}}", var));
636        }
637        result
638    }
639
640    fn generate_condition_check(&self, condition: &StepCondition) -> String {
641        let mut code = String::new();
642        code.push_str(&format!(
643            "    if let Some(val) = variables.get(\"{}\") {{\n",
644            condition.variable
645        ));
646
647        let check = match condition.operator {
648            ConditionOperator::Equals => format!("val == \"{}\"", condition.value),
649            ConditionOperator::NotEquals => format!("val != \"{}\"", condition.value),
650            ConditionOperator::Contains => format!("val.contains(\"{}\")", condition.value),
651            ConditionOperator::Exists => "true".to_string(),
652            ConditionOperator::GreaterThan => {
653                format!("val.parse::<f64>().unwrap_or(0.0) > {}", condition.value)
654            }
655            ConditionOperator::LessThan => {
656                format!("val.parse::<f64>().unwrap_or(0.0) < {}", condition.value)
657            }
658        };
659
660        code.push_str(&format!("        if !({}) {{\n", check));
661        code.push_str("            return; // Skip this step\n");
662        code.push_str("        }\n");
663        code.push_str("    }\n");
664        code
665    }
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    #[test]
673    fn test_workflow_creation() {
674        let workflow = IntegrationWorkflow {
675            id: "test-1".to_string(),
676            name: "User Registration Flow".to_string(),
677            description: "Test user registration and login".to_string(),
678            steps: vec![],
679            setup: WorkflowSetup::default(),
680            cleanup: vec![],
681            created_at: Utc::now(),
682        };
683
684        assert_eq!(workflow.name, "User Registration Flow");
685    }
686
687    #[test]
688    fn test_variable_extraction() {
689        let gen = IntegrationTestGenerator::new(IntegrationWorkflow {
690            id: "test".to_string(),
691            name: "test".to_string(),
692            description: "".to_string(),
693            steps: vec![],
694            setup: WorkflowSetup::default(),
695            cleanup: vec![],
696            created_at: Utc::now(),
697        });
698
699        let vars = gen.extract_variables("/api/users/{user_id}/posts/{post_id}");
700        assert_eq!(vars, vec!["user_id", "post_id"]);
701    }
702}