Skip to main content

mockforge_bench/conformance/
executor.rs

1//! Native Rust conformance test executor
2//!
3//! Replaces the k6-based execution path with direct HTTP requests via reqwest,
4//! producing the same `ConformanceReport` output. This removes the k6 binary
5//! dependency and enables API/SDK integration.
6
7use super::custom::{CustomCheck, CustomConformanceConfig};
8use super::generator::ConformanceConfig;
9use super::report::{ConformanceReport, FailureDetail, FailureRequest, FailureResponse};
10use super::spec::ConformanceFeature;
11use super::spec_driven::{AnnotatedOperation, ApiKeyLocation, SecuritySchemeInfo};
12use crate::error::{BenchError, Result};
13use reqwest::{Client, Method};
14use std::collections::{HashMap, HashSet};
15use std::time::Duration;
16use tokio::sync::mpsc;
17
18/// A field-level schema validation violation
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
20pub struct SchemaViolation {
21    /// JSON path to the field that failed validation (e.g., "/name", "/items/0/age")
22    pub field_path: String,
23    /// Type of violation (e.g., "type", "required", "additionalProperties")
24    pub violation_type: String,
25    /// What the schema expected
26    pub expected: String,
27    /// What was actually found
28    pub actual: String,
29}
30
31/// A single conformance check to execute
32#[derive(Debug, Clone)]
33pub struct ConformanceCheck {
34    /// Check name (e.g., "param:path:string" or "param:path:string:/users/{id}")
35    pub name: String,
36    /// HTTP method
37    pub method: Method,
38    /// Relative path (appended to base_url)
39    pub path: String,
40    /// Request headers
41    pub headers: Vec<(String, String)>,
42    /// Optional request body
43    pub body: Option<CheckBody>,
44    /// How to validate the response
45    pub validation: CheckValidation,
46}
47
48/// Request body variants
49#[derive(Debug, Clone)]
50pub enum CheckBody {
51    /// JSON body
52    Json(serde_json::Value),
53    /// Form-urlencoded body
54    FormUrlencoded(Vec<(String, String)>),
55    /// Raw string body with content type
56    Raw {
57        content: String,
58        content_type: String,
59    },
60}
61
62/// How to validate a conformance check response
63#[derive(Debug, Clone)]
64pub enum CheckValidation {
65    /// status >= min && status < max_exclusive
66    StatusRange { min: u16, max_exclusive: u16 },
67    /// status === code
68    ExactStatus(u16),
69    /// Schema validation: status in range + JSON body matches schema
70    SchemaValidation {
71        status_min: u16,
72        status_max: u16,
73        schema: serde_json::Value,
74    },
75    /// Custom: exact status + optional header regex + optional body field type checks
76    Custom {
77        expected_status: u16,
78        expected_headers: Vec<(String, String)>,
79        expected_body_fields: Vec<(String, String)>,
80    },
81}
82
83/// Progress event for SSE streaming
84#[derive(Debug, Clone, serde::Serialize)]
85#[serde(tag = "type")]
86pub enum ConformanceProgress {
87    /// Test run started
88    #[serde(rename = "started")]
89    Started { total_checks: usize },
90    /// A single check completed
91    #[serde(rename = "check_completed")]
92    CheckCompleted {
93        name: String,
94        passed: bool,
95        checks_done: usize,
96    },
97    /// All checks finished
98    #[serde(rename = "finished")]
99    Finished,
100    /// An error occurred
101    #[serde(rename = "error")]
102    Error { message: String },
103}
104
105/// Result of executing a single conformance check
106#[derive(Debug)]
107struct CheckResult {
108    name: String,
109    passed: bool,
110    failure_detail: Option<FailureDetail>,
111    /// Captured request/response data for --export-requests (always populated
112    /// when export_requests is enabled, regardless of pass/fail).
113    captured: Option<CapturedExchange>,
114}
115
116/// A captured HTTP exchange for the --export-requests feature
117#[derive(Debug, serde::Serialize)]
118struct CapturedExchange {
119    method: String,
120    url: String,
121    request_headers: HashMap<String, String>,
122    request_body: String,
123    response_status: u16,
124    response_headers: HashMap<String, String>,
125    response_body: String,
126}
127
128/// Native conformance executor using reqwest
129pub struct NativeConformanceExecutor {
130    config: ConformanceConfig,
131    client: Client,
132    checks: Vec<ConformanceCheck>,
133}
134
135impl NativeConformanceExecutor {
136    /// Create a new executor from a `ConformanceConfig`
137    pub fn new(config: ConformanceConfig) -> Result<Self> {
138        let mut builder = Client::builder()
139            .timeout(Duration::from_secs(30))
140            .connect_timeout(Duration::from_secs(10));
141
142        if config.skip_tls_verify {
143            builder = builder.danger_accept_invalid_certs(true);
144        }
145
146        let client = builder
147            .build()
148            .map_err(|e| BenchError::Other(format!("Failed to build HTTP client: {}", e)))?;
149
150        Ok(Self {
151            config,
152            client,
153            checks: Vec::new(),
154        })
155    }
156
157    /// Populate checks from hardcoded reference endpoints (`/conformance/*`).
158    /// Used when no `--spec` is provided.
159    #[must_use]
160    pub fn with_reference_checks(mut self) -> Self {
161        // --- Parameters ---
162        if self.config.should_include_category("Parameters") {
163            self.add_ref_get("param:path:string", "/conformance/params/hello");
164            self.add_ref_get("param:path:integer", "/conformance/params/42");
165            self.add_ref_get("param:query:string", "/conformance/params/query?name=test");
166            self.add_ref_get("param:query:integer", "/conformance/params/query?count=10");
167            self.add_ref_get("param:query:array", "/conformance/params/query?tags=a&tags=b");
168            self.checks.push(ConformanceCheck {
169                name: "param:header".to_string(),
170                method: Method::GET,
171                path: "/conformance/params/header".to_string(),
172                headers: self
173                    .merge_headers(vec![("X-Custom-Param".to_string(), "test-value".to_string())]),
174                body: None,
175                validation: CheckValidation::StatusRange {
176                    min: 200,
177                    max_exclusive: 500,
178                },
179            });
180            self.checks.push(ConformanceCheck {
181                name: "param:cookie".to_string(),
182                method: Method::GET,
183                path: "/conformance/params/cookie".to_string(),
184                headers: self
185                    .merge_headers(vec![("Cookie".to_string(), "session=abc123".to_string())]),
186                body: None,
187                validation: CheckValidation::StatusRange {
188                    min: 200,
189                    max_exclusive: 500,
190                },
191            });
192        }
193
194        // --- Request Bodies ---
195        if self.config.should_include_category("Request Bodies") {
196            self.checks.push(ConformanceCheck {
197                name: "body:json".to_string(),
198                method: Method::POST,
199                path: "/conformance/body/json".to_string(),
200                headers: self.merge_headers(vec![(
201                    "Content-Type".to_string(),
202                    "application/json".to_string(),
203                )]),
204                body: Some(CheckBody::Json(serde_json::json!({"name": "test", "value": 42}))),
205                validation: CheckValidation::StatusRange {
206                    min: 200,
207                    max_exclusive: 500,
208                },
209            });
210            self.checks.push(ConformanceCheck {
211                name: "body:form-urlencoded".to_string(),
212                method: Method::POST,
213                path: "/conformance/body/form".to_string(),
214                headers: self.custom_headers_only(),
215                body: Some(CheckBody::FormUrlencoded(vec![
216                    ("field1".to_string(), "value1".to_string()),
217                    ("field2".to_string(), "value2".to_string()),
218                ])),
219                validation: CheckValidation::StatusRange {
220                    min: 200,
221                    max_exclusive: 500,
222                },
223            });
224            self.checks.push(ConformanceCheck {
225                name: "body:multipart".to_string(),
226                method: Method::POST,
227                path: "/conformance/body/multipart".to_string(),
228                headers: self.custom_headers_only(),
229                body: Some(CheckBody::Raw {
230                    content: "test content".to_string(),
231                    content_type: "text/plain".to_string(),
232                }),
233                validation: CheckValidation::StatusRange {
234                    min: 200,
235                    max_exclusive: 500,
236                },
237            });
238        }
239
240        // --- Schema Types ---
241        if self.config.should_include_category("Schema Types") {
242            let types = [
243                ("string", r#"{"value": "hello"}"#, "schema:string"),
244                ("integer", r#"{"value": 42}"#, "schema:integer"),
245                ("number", r#"{"value": 3.14}"#, "schema:number"),
246                ("boolean", r#"{"value": true}"#, "schema:boolean"),
247                ("array", r#"{"value": [1, 2, 3]}"#, "schema:array"),
248                ("object", r#"{"value": {"nested": "data"}}"#, "schema:object"),
249            ];
250            for (type_name, body_str, check_name) in types {
251                self.checks.push(ConformanceCheck {
252                    name: check_name.to_string(),
253                    method: Method::POST,
254                    path: format!("/conformance/schema/{}", type_name),
255                    headers: self.merge_headers(vec![(
256                        "Content-Type".to_string(),
257                        "application/json".to_string(),
258                    )]),
259                    body: Some(CheckBody::Json(
260                        serde_json::from_str(body_str).expect("valid JSON"),
261                    )),
262                    validation: CheckValidation::StatusRange {
263                        min: 200,
264                        max_exclusive: 500,
265                    },
266                });
267            }
268        }
269
270        // --- Composition ---
271        if self.config.should_include_category("Composition") {
272            let compositions = [
273                ("oneOf", r#"{"type": "string", "value": "test"}"#, "composition:oneOf"),
274                ("anyOf", r#"{"value": "test"}"#, "composition:anyOf"),
275                ("allOf", r#"{"name": "test", "id": 1}"#, "composition:allOf"),
276            ];
277            for (kind, body_str, check_name) in compositions {
278                self.checks.push(ConformanceCheck {
279                    name: check_name.to_string(),
280                    method: Method::POST,
281                    path: format!("/conformance/composition/{}", kind),
282                    headers: self.merge_headers(vec![(
283                        "Content-Type".to_string(),
284                        "application/json".to_string(),
285                    )]),
286                    body: Some(CheckBody::Json(
287                        serde_json::from_str(body_str).expect("valid JSON"),
288                    )),
289                    validation: CheckValidation::StatusRange {
290                        min: 200,
291                        max_exclusive: 500,
292                    },
293                });
294            }
295        }
296
297        // --- String Formats ---
298        if self.config.should_include_category("String Formats") {
299            let formats = [
300                ("date", r#"{"value": "2024-01-15"}"#, "format:date"),
301                ("date-time", r#"{"value": "2024-01-15T10:30:00Z"}"#, "format:date-time"),
302                ("email", r#"{"value": "test@example.com"}"#, "format:email"),
303                ("uuid", r#"{"value": "550e8400-e29b-41d4-a716-446655440000"}"#, "format:uuid"),
304                ("uri", r#"{"value": "https://example.com/path"}"#, "format:uri"),
305                ("ipv4", r#"{"value": "192.168.1.1"}"#, "format:ipv4"),
306                ("ipv6", r#"{"value": "::1"}"#, "format:ipv6"),
307            ];
308            for (fmt, body_str, check_name) in formats {
309                self.checks.push(ConformanceCheck {
310                    name: check_name.to_string(),
311                    method: Method::POST,
312                    path: format!("/conformance/formats/{}", fmt),
313                    headers: self.merge_headers(vec![(
314                        "Content-Type".to_string(),
315                        "application/json".to_string(),
316                    )]),
317                    body: Some(CheckBody::Json(
318                        serde_json::from_str(body_str).expect("valid JSON"),
319                    )),
320                    validation: CheckValidation::StatusRange {
321                        min: 200,
322                        max_exclusive: 500,
323                    },
324                });
325            }
326        }
327
328        // --- Constraints ---
329        if self.config.should_include_category("Constraints") {
330            let constraints = [
331                ("required", r#"{"required_field": "present"}"#, "constraint:required"),
332                ("optional", r#"{}"#, "constraint:optional"),
333                ("minmax", r#"{"value": 50}"#, "constraint:minmax"),
334                ("pattern", r#"{"value": "ABC-123"}"#, "constraint:pattern"),
335                ("enum", r#"{"status": "active"}"#, "constraint:enum"),
336            ];
337            for (kind, body_str, check_name) in constraints {
338                self.checks.push(ConformanceCheck {
339                    name: check_name.to_string(),
340                    method: Method::POST,
341                    path: format!("/conformance/constraints/{}", kind),
342                    headers: self.merge_headers(vec![(
343                        "Content-Type".to_string(),
344                        "application/json".to_string(),
345                    )]),
346                    body: Some(CheckBody::Json(
347                        serde_json::from_str(body_str).expect("valid JSON"),
348                    )),
349                    validation: CheckValidation::StatusRange {
350                        min: 200,
351                        max_exclusive: 500,
352                    },
353                });
354            }
355        }
356
357        // --- Response Codes ---
358        if self.config.should_include_category("Response Codes") {
359            for (code_str, check_name) in [
360                ("200", "response:200"),
361                ("201", "response:201"),
362                ("204", "response:204"),
363                ("400", "response:400"),
364                ("404", "response:404"),
365            ] {
366                let code: u16 = code_str.parse().unwrap();
367                self.checks.push(ConformanceCheck {
368                    name: check_name.to_string(),
369                    method: Method::GET,
370                    path: format!("/conformance/responses/{}", code_str),
371                    headers: self.custom_headers_only(),
372                    body: None,
373                    validation: CheckValidation::ExactStatus(code),
374                });
375            }
376        }
377
378        // --- HTTP Methods ---
379        if self.config.should_include_category("HTTP Methods") {
380            self.add_ref_get("method:GET", "/conformance/methods");
381            for (method, check_name) in [
382                (Method::POST, "method:POST"),
383                (Method::PUT, "method:PUT"),
384                (Method::PATCH, "method:PATCH"),
385            ] {
386                self.checks.push(ConformanceCheck {
387                    name: check_name.to_string(),
388                    method,
389                    path: "/conformance/methods".to_string(),
390                    headers: self.merge_headers(vec![(
391                        "Content-Type".to_string(),
392                        "application/json".to_string(),
393                    )]),
394                    body: Some(CheckBody::Json(serde_json::json!({"action": "test"}))),
395                    validation: CheckValidation::StatusRange {
396                        min: 200,
397                        max_exclusive: 500,
398                    },
399                });
400            }
401            for (method, check_name) in [
402                (Method::DELETE, "method:DELETE"),
403                (Method::HEAD, "method:HEAD"),
404                (Method::OPTIONS, "method:OPTIONS"),
405            ] {
406                self.checks.push(ConformanceCheck {
407                    name: check_name.to_string(),
408                    method,
409                    path: "/conformance/methods".to_string(),
410                    headers: self.custom_headers_only(),
411                    body: None,
412                    validation: CheckValidation::StatusRange {
413                        min: 200,
414                        max_exclusive: 500,
415                    },
416                });
417            }
418        }
419
420        // --- Content Types ---
421        if self.config.should_include_category("Content Types") {
422            self.checks.push(ConformanceCheck {
423                name: "content:negotiation".to_string(),
424                method: Method::GET,
425                path: "/conformance/content-types".to_string(),
426                headers: self
427                    .merge_headers(vec![("Accept".to_string(), "application/json".to_string())]),
428                body: None,
429                validation: CheckValidation::StatusRange {
430                    min: 200,
431                    max_exclusive: 500,
432                },
433            });
434        }
435
436        // --- Security ---
437        if self.config.should_include_category("Security") {
438            // Bearer
439            self.checks.push(ConformanceCheck {
440                name: "security:bearer".to_string(),
441                method: Method::GET,
442                path: "/conformance/security/bearer".to_string(),
443                headers: self.merge_headers(vec![(
444                    "Authorization".to_string(),
445                    "Bearer test-token-123".to_string(),
446                )]),
447                body: None,
448                validation: CheckValidation::StatusRange {
449                    min: 200,
450                    max_exclusive: 500,
451                },
452            });
453
454            // API Key
455            let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
456            self.checks.push(ConformanceCheck {
457                name: "security:apikey".to_string(),
458                method: Method::GET,
459                path: "/conformance/security/apikey".to_string(),
460                headers: self.merge_headers(vec![("X-API-Key".to_string(), api_key.to_string())]),
461                body: None,
462                validation: CheckValidation::StatusRange {
463                    min: 200,
464                    max_exclusive: 500,
465                },
466            });
467
468            // Basic auth
469            let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
470            use base64::Engine;
471            let encoded = base64::engine::general_purpose::STANDARD.encode(basic_creds.as_bytes());
472            self.checks.push(ConformanceCheck {
473                name: "security:basic".to_string(),
474                method: Method::GET,
475                path: "/conformance/security/basic".to_string(),
476                headers: self.merge_headers(vec![(
477                    "Authorization".to_string(),
478                    format!("Basic {}", encoded),
479                )]),
480                body: None,
481                validation: CheckValidation::StatusRange {
482                    min: 200,
483                    max_exclusive: 500,
484                },
485            });
486        }
487
488        self
489    }
490
491    /// Populate checks from annotated spec operations (spec-driven mode)
492    #[must_use]
493    pub fn with_spec_driven_checks(mut self, operations: &[AnnotatedOperation]) -> Self {
494        // Track which features have been seen to deduplicate in default mode
495        let mut feature_seen: HashSet<&'static str> = HashSet::new();
496
497        for op in operations {
498            for feature in &op.features {
499                let category = feature.category();
500                if !self.config.should_include_category(category) {
501                    continue;
502                }
503
504                let check_name_base = feature.check_name();
505
506                if self.config.all_operations {
507                    // All-operations mode: test every operation with path-qualified names
508                    let check_name = format!("{}:{}", check_name_base, op.path);
509                    let check = self.build_spec_check(&check_name, op, feature);
510                    self.checks.push(check);
511                } else {
512                    // Default mode: one representative operation per feature
513                    if feature_seen.insert(check_name_base) {
514                        let check_name = format!("{}:{}", check_name_base, op.path);
515                        let check = self.build_spec_check(&check_name, op, feature);
516                        self.checks.push(check);
517                    }
518                }
519            }
520        }
521
522        self
523    }
524
525    /// Load custom checks from the configured YAML file
526    pub fn with_custom_checks(mut self) -> Result<Self> {
527        let path = match &self.config.custom_checks_file {
528            Some(p) => p.clone(),
529            None => return Ok(self),
530        };
531        let custom_config = CustomConformanceConfig::from_file(&path)?;
532
533        // Compile the custom filter regex (if provided)
534        let filter_re = match &self.config.custom_filter {
535            Some(pattern) => Some(regex::Regex::new(pattern).map_err(|e| {
536                BenchError::Other(format!("Invalid --conformance-custom-filter regex: {}", e))
537            })?),
538            None => None,
539        };
540
541        let mut included = 0usize;
542        let total = custom_config.custom_checks.len();
543        for check in &custom_config.custom_checks {
544            if let Some(ref re) = filter_re {
545                if !re.is_match(&check.name) && !re.is_match(&check.path) {
546                    continue;
547                }
548            }
549            self.add_custom_check(check);
550            included += 1;
551        }
552
553        if filter_re.is_some() {
554            tracing::info!("Custom check filter: {}/{} checks matched pattern", included, total);
555        }
556
557        Ok(self)
558    }
559
560    /// Return the number of checks that will be executed
561    pub fn check_count(&self) -> usize {
562        self.checks.len()
563    }
564
565    /// Execute all checks and return a `ConformanceReport`
566    pub async fn execute(&self) -> Result<ConformanceReport> {
567        let mut results = Vec::with_capacity(self.checks.len());
568        let delay = self.config.request_delay_ms;
569
570        for (i, check) in self.checks.iter().enumerate() {
571            if delay > 0 && i > 0 {
572                tokio::time::sleep(Duration::from_millis(delay)).await;
573            }
574            results.push(self.execute_check(check).await);
575        }
576
577        // Write request log if --export-requests was set
578        if self.config.export_requests {
579            if let Some(ref output_dir) = self.config.output_dir {
580                let request_log: Vec<_> = results
581                    .iter()
582                    .filter_map(|r| {
583                        r.captured.as_ref().map(|c| {
584                            serde_json::json!({
585                                "check": r.name,
586                                "passed": r.passed,
587                                "request": {
588                                    "method": c.method,
589                                    "url": c.url,
590                                    "headers": c.request_headers,
591                                    "body": c.request_body,
592                                },
593                                "response": {
594                                    "status": c.response_status,
595                                    "headers": c.response_headers,
596                                    "body": c.response_body,
597                                },
598                            })
599                        })
600                    })
601                    .collect();
602                let path = output_dir.join("conformance-requests.json");
603                if let Ok(json) = serde_json::to_string_pretty(&request_log) {
604                    let _ = std::fs::write(&path, json);
605                    tracing::info!(
606                        "Exported {} request/response pairs to {}",
607                        request_log.len(),
608                        path.display()
609                    );
610                }
611            }
612        }
613
614        Ok(Self::aggregate(results))
615    }
616
617    /// Execute all checks with progress events sent to the channel
618    pub async fn execute_with_progress(
619        &self,
620        tx: mpsc::Sender<ConformanceProgress>,
621    ) -> Result<ConformanceReport> {
622        let total = self.checks.len();
623        let delay = self.config.request_delay_ms;
624        let _ = tx
625            .send(ConformanceProgress::Started {
626                total_checks: total,
627            })
628            .await;
629
630        let mut results = Vec::with_capacity(total);
631
632        for (i, check) in self.checks.iter().enumerate() {
633            if delay > 0 && i > 0 {
634                tokio::time::sleep(Duration::from_millis(delay)).await;
635            }
636            let result = self.execute_check(check).await;
637            let passed = result.passed;
638            let name = result.name.clone();
639            results.push(result);
640
641            let _ = tx
642                .send(ConformanceProgress::CheckCompleted {
643                    name,
644                    passed,
645                    checks_done: i + 1,
646                })
647                .await;
648        }
649
650        let _ = tx.send(ConformanceProgress::Finished).await;
651        Ok(Self::aggregate(results))
652    }
653
654    /// Execute a single check
655    async fn execute_check(&self, check: &ConformanceCheck) -> CheckResult {
656        let base_url = self.config.effective_base_url();
657        let url = format!("{}{}", base_url.trim_end_matches('/'), check.path);
658
659        let mut request = self.client.request(check.method.clone(), &url);
660
661        // Add headers
662        for (name, value) in &check.headers {
663            request = request.header(name.as_str(), value.as_str());
664        }
665
666        // Add body
667        match &check.body {
668            Some(CheckBody::Json(value)) => {
669                request = request.json(value);
670            }
671            Some(CheckBody::FormUrlencoded(fields)) => {
672                request = request.form(fields);
673            }
674            Some(CheckBody::Raw {
675                content,
676                content_type,
677            }) => {
678                // For multipart, use the multipart API
679                if content_type == "text/plain" && check.path.contains("multipart") {
680                    let part = reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
681                        .file_name("test.txt")
682                        .mime_str(content_type)
683                        .unwrap_or_else(|_| {
684                            reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
685                        });
686                    let form = reqwest::multipart::Form::new().part("field", part);
687                    request = request.multipart(form);
688                } else {
689                    request =
690                        request.header("Content-Type", content_type.as_str()).body(content.clone());
691                }
692            }
693            None => {}
694        }
695
696        let req_body_str = match &check.body {
697            Some(CheckBody::Json(v)) => v.to_string(),
698            Some(CheckBody::FormUrlencoded(f)) => {
699                f.iter().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join("&")
700            }
701            Some(CheckBody::Raw { content, .. }) => content.clone(),
702            None => String::new(),
703        };
704
705        let response = match request.send().await {
706            Ok(resp) => resp,
707            Err(e) => {
708                return CheckResult {
709                    name: check.name.clone(),
710                    passed: false,
711                    failure_detail: Some(FailureDetail {
712                        check: check.name.clone(),
713                        request: FailureRequest {
714                            method: check.method.to_string(),
715                            url: url.clone(),
716                            headers: HashMap::new(),
717                            body: String::new(),
718                        },
719                        response: FailureResponse {
720                            status: 0,
721                            headers: HashMap::new(),
722                            body: format!("Request failed: {}", e),
723                        },
724                        expected: format!("{:?}", check.validation),
725                        schema_violations: Vec::new(),
726                    }),
727                    captured: None,
728                };
729            }
730        };
731
732        let status = response.status().as_u16();
733        let resp_headers: HashMap<String, String> = response
734            .headers()
735            .iter()
736            .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
737            .collect();
738        let resp_body = response.text().await.unwrap_or_default();
739
740        let (passed, schema_violations) =
741            self.validate_response(&check.validation, status, &resp_headers, &resp_body);
742
743        // Always capture the exchange when export_requests is enabled
744        let captured = if self.config.export_requests {
745            Some(CapturedExchange {
746                method: check.method.to_string(),
747                url: url.clone(),
748                request_headers: check.headers.iter().cloned().collect(),
749                request_body: req_body_str,
750                response_status: status,
751                response_headers: resp_headers.clone(),
752                response_body: if resp_body.len() > 2000 {
753                    format!("{}...(truncated)", &resp_body[..2000])
754                } else {
755                    resp_body.clone()
756                },
757            })
758        } else {
759            None
760        };
761
762        let failure_detail = if !passed {
763            Some(FailureDetail {
764                check: check.name.clone(),
765                request: FailureRequest {
766                    method: check.method.to_string(),
767                    url,
768                    headers: check.headers.iter().cloned().collect(),
769                    body: match &check.body {
770                        Some(CheckBody::Json(v)) => v.to_string(),
771                        Some(CheckBody::FormUrlencoded(f)) => f
772                            .iter()
773                            .map(|(k, v)| format!("{}={}", k, v))
774                            .collect::<Vec<_>>()
775                            .join("&"),
776                        Some(CheckBody::Raw { content, .. }) => content.clone(),
777                        None => String::new(),
778                    },
779                },
780                response: FailureResponse {
781                    status,
782                    headers: resp_headers,
783                    body: if resp_body.len() > 500 {
784                        format!("{}...", &resp_body[..500])
785                    } else {
786                        resp_body
787                    },
788                },
789                expected: Self::describe_validation(&check.validation),
790                schema_violations,
791            })
792        } else {
793            None
794        };
795
796        CheckResult {
797            name: check.name.clone(),
798            passed,
799            failure_detail,
800            captured,
801        }
802    }
803
804    /// Validate a response against the check's validation rules.
805    ///
806    /// Returns `(passed, schema_violations)` where `schema_violations` contains
807    /// field-level details when a `SchemaValidation` check fails.
808    fn validate_response(
809        &self,
810        validation: &CheckValidation,
811        status: u16,
812        headers: &HashMap<String, String>,
813        body: &str,
814    ) -> (bool, Vec<SchemaViolation>) {
815        match validation {
816            CheckValidation::StatusRange { min, max_exclusive } => {
817                (status >= *min && status < *max_exclusive, Vec::new())
818            }
819            CheckValidation::ExactStatus(expected) => (status == *expected, Vec::new()),
820            CheckValidation::SchemaValidation {
821                status_min,
822                status_max,
823                schema,
824            } => {
825                if status < *status_min || status >= *status_max {
826                    return (false, Vec::new());
827                }
828                // Parse body as JSON and validate against schema
829                let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
830                    return (
831                        false,
832                        vec![SchemaViolation {
833                            field_path: "/".to_string(),
834                            violation_type: "parse_error".to_string(),
835                            expected: "valid JSON".to_string(),
836                            actual: "non-JSON response body".to_string(),
837                        }],
838                    );
839                };
840                match jsonschema::validator_for(schema) {
841                    Ok(validator) => {
842                        let errors: Vec<_> = validator.iter_errors(&body_value).collect();
843                        if errors.is_empty() {
844                            (true, Vec::new())
845                        } else {
846                            let violations = errors
847                                .iter()
848                                .map(|err| {
849                                    let field_path = err.instance_path.to_string();
850                                    let field_path = if field_path.is_empty() {
851                                        "/".to_string()
852                                    } else {
853                                        field_path
854                                    };
855                                    SchemaViolation {
856                                        field_path,
857                                        violation_type: format!("{:?}", err.kind)
858                                            .split('(')
859                                            .next()
860                                            .unwrap_or("unknown")
861                                            .split('{')
862                                            .next()
863                                            .unwrap_or("unknown")
864                                            .split(' ')
865                                            .next()
866                                            .unwrap_or("unknown")
867                                            .trim()
868                                            .to_string(),
869                                        expected: {
870                                            // Extract human-readable expected value from the error.
871                                            // The schema_path is like "/properties/field/type" —
872                                            // extract the last meaningful segment instead.
873                                            let schema_str = format!("{}", err.schema_path);
874                                            match &err.kind {
875                                                jsonschema::error::ValidationErrorKind::Type { kind } => {
876                                                    format!("type: {:?}", kind)
877                                                }
878                                                jsonschema::error::ValidationErrorKind::Required { property } => {
879                                                    format!("required field: {}", property)
880                                                }
881                                                _ => {
882                                                    // For other kinds, use last path segment
883                                                    schema_str
884                                                        .rsplit('/')
885                                                        .next()
886                                                        .unwrap_or(&schema_str)
887                                                        .to_string()
888                                                }
889                                            }
890                                        },
891                                        actual: format!("{}", err),
892                                    }
893                                })
894                                .collect();
895                            (false, violations)
896                        }
897                    }
898                    Err(_) => {
899                        // Schema compilation failed — fall back to is_valid behavior
900                        (
901                            false,
902                            vec![SchemaViolation {
903                                field_path: "/".to_string(),
904                                violation_type: "schema_compile_error".to_string(),
905                                expected: "valid JSON schema".to_string(),
906                                actual: "schema failed to compile".to_string(),
907                            }],
908                        )
909                    }
910                }
911            }
912            CheckValidation::Custom {
913                expected_status,
914                expected_headers,
915                expected_body_fields,
916            } => {
917                if status != *expected_status {
918                    return (false, Vec::new());
919                }
920                // Check headers with regex
921                for (header_name, pattern) in expected_headers {
922                    let header_val = headers
923                        .get(header_name)
924                        .or_else(|| headers.get(&header_name.to_lowercase()))
925                        .map(|s| s.as_str())
926                        .unwrap_or("");
927                    if let Ok(re) = regex::Regex::new(pattern) {
928                        if !re.is_match(header_val) {
929                            return (false, Vec::new());
930                        }
931                    }
932                }
933                // Check body field types
934                if !expected_body_fields.is_empty() {
935                    let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
936                        return (false, Vec::new());
937                    };
938                    for (field_name, field_type) in expected_body_fields {
939                        let field = &body_value[field_name];
940                        let ok = match field_type.as_str() {
941                            "string" => field.is_string(),
942                            "integer" => field.is_i64() || field.is_u64(),
943                            "number" => field.is_number(),
944                            "boolean" => field.is_boolean(),
945                            "array" => field.is_array(),
946                            "object" => field.is_object(),
947                            _ => !field.is_null(),
948                        };
949                        if !ok {
950                            return (false, Vec::new());
951                        }
952                    }
953                }
954                (true, Vec::new())
955            }
956        }
957    }
958
959    /// Human-readable validation description for failure reports
960    fn describe_validation(validation: &CheckValidation) -> String {
961        match validation {
962            CheckValidation::StatusRange { min, max_exclusive } => {
963                format!("status >= {} && status < {}", min, max_exclusive)
964            }
965            CheckValidation::ExactStatus(code) => format!("status === {}", code),
966            CheckValidation::SchemaValidation {
967                status_min,
968                status_max,
969                ..
970            } => {
971                format!("status >= {} && status < {} + schema validation", status_min, status_max)
972            }
973            CheckValidation::Custom {
974                expected_status, ..
975            } => {
976                format!("status === {}", expected_status)
977            }
978        }
979    }
980
981    /// Aggregate check results into a `ConformanceReport`
982    fn aggregate(results: Vec<CheckResult>) -> ConformanceReport {
983        let mut check_results: HashMap<String, (u64, u64)> = HashMap::new();
984        let mut failure_details = Vec::new();
985
986        for result in results {
987            let entry = check_results.entry(result.name.clone()).or_insert((0, 0));
988            if result.passed {
989                entry.0 += 1;
990            } else {
991                entry.1 += 1;
992            }
993            if let Some(detail) = result.failure_detail {
994                failure_details.push(detail);
995            }
996        }
997
998        ConformanceReport::from_results(check_results, failure_details)
999    }
1000
1001    // --- Helper methods ---
1002
1003    /// Build a spec-driven check from an annotated operation and feature
1004    fn build_spec_check(
1005        &self,
1006        check_name: &str,
1007        op: &AnnotatedOperation,
1008        feature: &ConformanceFeature,
1009    ) -> ConformanceCheck {
1010        // Build URL path with parameters substituted
1011        let mut url_path = op.path.clone();
1012        for (name, value) in &op.path_params {
1013            url_path = url_path.replace(&format!("{{{}}}", name), value);
1014        }
1015        // Append query params
1016        if !op.query_params.is_empty() {
1017            let qs: Vec<String> =
1018                op.query_params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
1019            url_path = format!("{}?{}", url_path, qs.join("&"));
1020        }
1021
1022        // Build effective headers
1023        let mut effective_headers = self.effective_headers(&op.header_params);
1024
1025        // For non-default response codes, add mock server header
1026        if matches!(feature, ConformanceFeature::Response400 | ConformanceFeature::Response404) {
1027            let code = match feature {
1028                ConformanceFeature::Response400 => "400",
1029                ConformanceFeature::Response404 => "404",
1030                _ => unreachable!(),
1031            };
1032            effective_headers.push(("X-Mockforge-Response-Status".to_string(), code.to_string()));
1033        }
1034
1035        // Inject auth headers for security checks or secured endpoints
1036        let needs_auth = matches!(
1037            feature,
1038            ConformanceFeature::SecurityBearer
1039                | ConformanceFeature::SecurityBasic
1040                | ConformanceFeature::SecurityApiKey
1041        ) || !op.security_schemes.is_empty();
1042
1043        if needs_auth {
1044            self.inject_security_headers(&op.security_schemes, &mut effective_headers);
1045        }
1046
1047        // Determine method
1048        let method = match op.method.as_str() {
1049            "GET" => Method::GET,
1050            "POST" => Method::POST,
1051            "PUT" => Method::PUT,
1052            "PATCH" => Method::PATCH,
1053            "DELETE" => Method::DELETE,
1054            "HEAD" => Method::HEAD,
1055            "OPTIONS" => Method::OPTIONS,
1056            _ => Method::GET,
1057        };
1058
1059        // Determine body
1060        let body = match method {
1061            Method::POST | Method::PUT | Method::PATCH => {
1062                if let Some(sample) = &op.sample_body {
1063                    // Add Content-Type if not present
1064                    let content_type =
1065                        op.request_body_content_type.as_deref().unwrap_or("application/json");
1066                    if !effective_headers
1067                        .iter()
1068                        .any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1069                    {
1070                        effective_headers
1071                            .push(("Content-Type".to_string(), content_type.to_string()));
1072                    }
1073                    match content_type {
1074                        "application/x-www-form-urlencoded" => {
1075                            // Parse as form fields
1076                            let fields: Vec<(String, String)> = serde_json::from_str::<
1077                                serde_json::Value,
1078                            >(
1079                                sample
1080                            )
1081                            .ok()
1082                            .and_then(|v| {
1083                                v.as_object().map(|obj| {
1084                                    obj.iter()
1085                                        .map(|(k, v)| {
1086                                            (k.clone(), v.as_str().unwrap_or("").to_string())
1087                                        })
1088                                        .collect()
1089                                })
1090                            })
1091                            .unwrap_or_default();
1092                            Some(CheckBody::FormUrlencoded(fields))
1093                        }
1094                        _ => {
1095                            // Try JSON, fall back to raw
1096                            match serde_json::from_str::<serde_json::Value>(sample) {
1097                                Ok(v) => Some(CheckBody::Json(v)),
1098                                Err(_) => Some(CheckBody::Raw {
1099                                    content: sample.clone(),
1100                                    content_type: content_type.to_string(),
1101                                }),
1102                            }
1103                        }
1104                    }
1105                } else {
1106                    None
1107                }
1108            }
1109            _ => None,
1110        };
1111
1112        // Determine validation
1113        let validation = self.determine_validation(feature, op);
1114
1115        ConformanceCheck {
1116            name: check_name.to_string(),
1117            method,
1118            path: url_path,
1119            headers: effective_headers,
1120            body,
1121            validation,
1122        }
1123    }
1124
1125    /// Determine validation strategy based on the conformance feature
1126    fn determine_validation(
1127        &self,
1128        feature: &ConformanceFeature,
1129        op: &AnnotatedOperation,
1130    ) -> CheckValidation {
1131        match feature {
1132            ConformanceFeature::Response200 => CheckValidation::ExactStatus(200),
1133            ConformanceFeature::Response201 => CheckValidation::ExactStatus(201),
1134            ConformanceFeature::Response204 => CheckValidation::ExactStatus(204),
1135            ConformanceFeature::Response400 => CheckValidation::ExactStatus(400),
1136            ConformanceFeature::Response404 => CheckValidation::ExactStatus(404),
1137            ConformanceFeature::SecurityBearer
1138            | ConformanceFeature::SecurityBasic
1139            | ConformanceFeature::SecurityApiKey => CheckValidation::StatusRange {
1140                min: 200,
1141                max_exclusive: 400,
1142            },
1143            ConformanceFeature::ResponseValidation => {
1144                if let Some(schema) = &op.response_schema {
1145                    // Convert openapiv3 Schema to JSON Schema value for jsonschema crate
1146                    let schema_json = openapi_schema_to_json_schema(schema);
1147                    CheckValidation::SchemaValidation {
1148                        status_min: 200,
1149                        status_max: 500,
1150                        schema: schema_json,
1151                    }
1152                } else {
1153                    CheckValidation::StatusRange {
1154                        min: 200,
1155                        max_exclusive: 500,
1156                    }
1157                }
1158            }
1159            _ => CheckValidation::StatusRange {
1160                min: 200,
1161                max_exclusive: 500,
1162            },
1163        }
1164    }
1165
1166    /// Add a simple GET reference check with default status range validation
1167    fn add_ref_get(&mut self, name: &str, path: &str) {
1168        self.checks.push(ConformanceCheck {
1169            name: name.to_string(),
1170            method: Method::GET,
1171            path: path.to_string(),
1172            headers: self.custom_headers_only(),
1173            body: None,
1174            validation: CheckValidation::StatusRange {
1175                min: 200,
1176                max_exclusive: 500,
1177            },
1178        });
1179    }
1180
1181    /// Merge spec-derived headers with custom headers (custom overrides spec)
1182    fn effective_headers(&self, spec_headers: &[(String, String)]) -> Vec<(String, String)> {
1183        let mut headers = Vec::new();
1184        for (k, v) in spec_headers {
1185            // Skip if custom headers override this one
1186            if self.config.custom_headers.iter().any(|(ck, _)| ck.eq_ignore_ascii_case(k)) {
1187                continue;
1188            }
1189            headers.push((k.clone(), v.clone()));
1190        }
1191        // Append custom headers
1192        headers.extend(self.config.custom_headers.clone());
1193        headers
1194    }
1195
1196    /// Merge provided headers with custom headers
1197    fn merge_headers(&self, mut headers: Vec<(String, String)>) -> Vec<(String, String)> {
1198        for (k, v) in &self.config.custom_headers {
1199            if !headers.iter().any(|(hk, _)| hk.eq_ignore_ascii_case(k)) {
1200                headers.push((k.clone(), v.clone()));
1201            }
1202        }
1203        headers
1204    }
1205
1206    /// Return only custom headers (for checks that don't have spec-derived headers)
1207    fn custom_headers_only(&self) -> Vec<(String, String)> {
1208        self.config.custom_headers.clone()
1209    }
1210
1211    /// Inject security headers based on resolved security schemes.
1212    /// If the user provides a Cookie header via --conformance-header, skip automatic
1213    /// Authorization headers (Bearer/Basic) since the user manages their own auth.
1214    fn inject_security_headers(
1215        &self,
1216        schemes: &[SecuritySchemeInfo],
1217        headers: &mut Vec<(String, String)>,
1218    ) {
1219        // If user provides Cookie header, they're using session-based auth — skip auto auth
1220        let has_cookie_auth =
1221            self.config.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("Cookie"));
1222        let mut to_add: Vec<(String, String)> = Vec::new();
1223
1224        for scheme in schemes {
1225            match scheme {
1226                SecuritySchemeInfo::Bearer => {
1227                    if !has_cookie_auth
1228                        && !Self::header_present(
1229                            "Authorization",
1230                            headers,
1231                            &self.config.custom_headers,
1232                        )
1233                    {
1234                        to_add.push((
1235                            "Authorization".to_string(),
1236                            "Bearer mockforge-conformance-test-token".to_string(),
1237                        ));
1238                    }
1239                }
1240                SecuritySchemeInfo::Basic => {
1241                    if !has_cookie_auth
1242                        && !Self::header_present(
1243                            "Authorization",
1244                            headers,
1245                            &self.config.custom_headers,
1246                        )
1247                    {
1248                        let creds = self.config.basic_auth.as_deref().unwrap_or("test:test");
1249                        use base64::Engine;
1250                        let encoded =
1251                            base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
1252                        to_add.push(("Authorization".to_string(), format!("Basic {}", encoded)));
1253                    }
1254                }
1255                SecuritySchemeInfo::ApiKey { location, name } => match location {
1256                    ApiKeyLocation::Header => {
1257                        if !Self::header_present(name, headers, &self.config.custom_headers) {
1258                            let key = self
1259                                .config
1260                                .api_key
1261                                .as_deref()
1262                                .unwrap_or("mockforge-conformance-test-key");
1263                            to_add.push((name.clone(), key.to_string()));
1264                        }
1265                    }
1266                    ApiKeyLocation::Cookie => {
1267                        if !Self::header_present("Cookie", headers, &self.config.custom_headers) {
1268                            to_add.push((
1269                                "Cookie".to_string(),
1270                                format!("{}=mockforge-conformance-test-session", name),
1271                            ));
1272                        }
1273                    }
1274                    ApiKeyLocation::Query => {
1275                        // Handled in URL, not headers
1276                    }
1277                },
1278            }
1279        }
1280
1281        headers.extend(to_add);
1282    }
1283
1284    /// Check if a header name is present in either the existing headers or custom headers
1285    fn header_present(
1286        name: &str,
1287        headers: &[(String, String)],
1288        custom_headers: &[(String, String)],
1289    ) -> bool {
1290        headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1291            || custom_headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1292    }
1293
1294    /// Add a custom check from YAML config
1295    fn add_custom_check(&mut self, check: &CustomCheck) {
1296        let method = match check.method.to_uppercase().as_str() {
1297            "GET" => Method::GET,
1298            "POST" => Method::POST,
1299            "PUT" => Method::PUT,
1300            "PATCH" => Method::PATCH,
1301            "DELETE" => Method::DELETE,
1302            "HEAD" => Method::HEAD,
1303            "OPTIONS" => Method::OPTIONS,
1304            _ => Method::GET,
1305        };
1306
1307        // Build headers
1308        let mut headers: Vec<(String, String)> =
1309            check.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1310        // Add global custom headers (check-specific take priority)
1311        for (k, v) in &self.config.custom_headers {
1312            if !check.headers.contains_key(k) {
1313                headers.push((k.clone(), v.clone()));
1314            }
1315        }
1316        // Add Content-Type for JSON body if not present
1317        if check.body.is_some()
1318            && !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1319        {
1320            headers.push(("Content-Type".to_string(), "application/json".to_string()));
1321        }
1322
1323        // Body
1324        let body = check
1325            .body
1326            .as_ref()
1327            .and_then(|b| serde_json::from_str::<serde_json::Value>(b).ok().map(CheckBody::Json));
1328
1329        // Build expected headers for validation
1330        let expected_headers: Vec<(String, String)> =
1331            check.expected_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1332
1333        // Build expected body fields
1334        let expected_body_fields: Vec<(String, String)> = check
1335            .expected_body_fields
1336            .iter()
1337            .map(|f| (f.name.clone(), f.field_type.clone()))
1338            .collect();
1339
1340        // Primary status check
1341        self.checks.push(ConformanceCheck {
1342            name: check.name.clone(),
1343            method,
1344            path: check.path.clone(),
1345            headers,
1346            body,
1347            validation: CheckValidation::Custom {
1348                expected_status: check.expected_status,
1349                expected_headers,
1350                expected_body_fields,
1351            },
1352        });
1353    }
1354}
1355
1356/// Convert an `openapiv3::Schema` to a JSON Schema `serde_json::Value`
1357/// suitable for use with the `jsonschema` crate.
1358fn openapi_schema_to_json_schema(schema: &openapiv3::Schema) -> serde_json::Value {
1359    use openapiv3::{SchemaKind, Type};
1360
1361    match &schema.schema_kind {
1362        SchemaKind::Type(Type::Object(obj)) => {
1363            let mut props = serde_json::Map::new();
1364            for (name, prop_ref) in &obj.properties {
1365                if let openapiv3::ReferenceOr::Item(prop_schema) = prop_ref {
1366                    props.insert(name.clone(), openapi_schema_to_json_schema(prop_schema));
1367                }
1368            }
1369            let mut schema_obj = serde_json::json!({
1370                "type": "object",
1371                "properties": props,
1372            });
1373            if !obj.required.is_empty() {
1374                schema_obj["required"] = serde_json::Value::Array(
1375                    obj.required.iter().map(|s| serde_json::json!(s)).collect(),
1376                );
1377            }
1378            schema_obj
1379        }
1380        SchemaKind::Type(Type::Array(arr)) => {
1381            let mut schema_obj = serde_json::json!({"type": "array"});
1382            if let Some(openapiv3::ReferenceOr::Item(item_schema)) = &arr.items {
1383                schema_obj["items"] = openapi_schema_to_json_schema(item_schema);
1384            }
1385            schema_obj
1386        }
1387        SchemaKind::Type(Type::String(s)) => {
1388            let mut obj = serde_json::json!({"type": "string"});
1389            if let Some(min) = s.min_length {
1390                obj["minLength"] = serde_json::json!(min);
1391            }
1392            if let Some(max) = s.max_length {
1393                obj["maxLength"] = serde_json::json!(max);
1394            }
1395            if let Some(pattern) = &s.pattern {
1396                obj["pattern"] = serde_json::json!(pattern);
1397            }
1398            if !s.enumeration.is_empty() {
1399                obj["enum"] = serde_json::Value::Array(
1400                    s.enumeration
1401                        .iter()
1402                        .filter_map(|v| v.as_ref().map(|s| serde_json::json!(s)))
1403                        .collect(),
1404                );
1405            }
1406            obj
1407        }
1408        SchemaKind::Type(Type::Integer(_)) => serde_json::json!({"type": "integer"}),
1409        SchemaKind::Type(Type::Number(_)) => serde_json::json!({"type": "number"}),
1410        SchemaKind::Type(Type::Boolean(_)) => serde_json::json!({"type": "boolean"}),
1411        _ => serde_json::json!({}),
1412    }
1413}
1414
1415#[cfg(test)]
1416mod tests {
1417    use super::*;
1418
1419    #[test]
1420    fn test_reference_check_count() {
1421        let config = ConformanceConfig {
1422            target_url: "http://localhost:3000".to_string(),
1423            ..Default::default()
1424        };
1425        let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1426        // 7 params + 3 bodies + 6 schema + 3 composition + 7 formats + 5 constraints
1427        // + 5 response codes + 7 methods + 1 content + 3 security = 47
1428        assert_eq!(executor.check_count(), 47);
1429    }
1430
1431    #[test]
1432    fn test_reference_checks_with_category_filter() {
1433        let config = ConformanceConfig {
1434            target_url: "http://localhost:3000".to_string(),
1435            categories: Some(vec!["Parameters".to_string()]),
1436            ..Default::default()
1437        };
1438        let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1439        assert_eq!(executor.check_count(), 7);
1440    }
1441
1442    #[test]
1443    fn test_validate_status_range() {
1444        let config = ConformanceConfig {
1445            target_url: "http://localhost:3000".to_string(),
1446            ..Default::default()
1447        };
1448        let executor = NativeConformanceExecutor::new(config).unwrap();
1449        let headers = HashMap::new();
1450
1451        assert!(
1452            executor
1453                .validate_response(
1454                    &CheckValidation::StatusRange {
1455                        min: 200,
1456                        max_exclusive: 500,
1457                    },
1458                    200,
1459                    &headers,
1460                    "",
1461                )
1462                .0
1463        );
1464        assert!(
1465            executor
1466                .validate_response(
1467                    &CheckValidation::StatusRange {
1468                        min: 200,
1469                        max_exclusive: 500,
1470                    },
1471                    404,
1472                    &headers,
1473                    "",
1474                )
1475                .0
1476        );
1477        assert!(
1478            !executor
1479                .validate_response(
1480                    &CheckValidation::StatusRange {
1481                        min: 200,
1482                        max_exclusive: 500,
1483                    },
1484                    500,
1485                    &headers,
1486                    "",
1487                )
1488                .0
1489        );
1490    }
1491
1492    #[test]
1493    fn test_validate_exact_status() {
1494        let config = ConformanceConfig {
1495            target_url: "http://localhost:3000".to_string(),
1496            ..Default::default()
1497        };
1498        let executor = NativeConformanceExecutor::new(config).unwrap();
1499        let headers = HashMap::new();
1500
1501        assert!(
1502            executor
1503                .validate_response(&CheckValidation::ExactStatus(200), 200, &headers, "")
1504                .0
1505        );
1506        assert!(
1507            !executor
1508                .validate_response(&CheckValidation::ExactStatus(200), 201, &headers, "")
1509                .0
1510        );
1511    }
1512
1513    #[test]
1514    fn test_validate_schema() {
1515        let config = ConformanceConfig {
1516            target_url: "http://localhost:3000".to_string(),
1517            ..Default::default()
1518        };
1519        let executor = NativeConformanceExecutor::new(config).unwrap();
1520        let headers = HashMap::new();
1521
1522        let schema = serde_json::json!({
1523            "type": "object",
1524            "properties": {
1525                "name": {"type": "string"},
1526                "age": {"type": "integer"}
1527            },
1528            "required": ["name"]
1529        });
1530
1531        let (passed, violations) = executor.validate_response(
1532            &CheckValidation::SchemaValidation {
1533                status_min: 200,
1534                status_max: 300,
1535                schema: schema.clone(),
1536            },
1537            200,
1538            &headers,
1539            r#"{"name": "test", "age": 25}"#,
1540        );
1541        assert!(passed);
1542        assert!(violations.is_empty());
1543
1544        // Missing required field
1545        let (passed, violations) = executor.validate_response(
1546            &CheckValidation::SchemaValidation {
1547                status_min: 200,
1548                status_max: 300,
1549                schema: schema.clone(),
1550            },
1551            200,
1552            &headers,
1553            r#"{"age": 25}"#,
1554        );
1555        assert!(!passed);
1556        assert!(!violations.is_empty());
1557        assert_eq!(violations[0].violation_type, "Required");
1558    }
1559
1560    #[test]
1561    fn test_validate_custom() {
1562        let config = ConformanceConfig {
1563            target_url: "http://localhost:3000".to_string(),
1564            ..Default::default()
1565        };
1566        let executor = NativeConformanceExecutor::new(config).unwrap();
1567        let mut headers = HashMap::new();
1568        headers.insert("content-type".to_string(), "application/json".to_string());
1569
1570        assert!(
1571            executor
1572                .validate_response(
1573                    &CheckValidation::Custom {
1574                        expected_status: 200,
1575                        expected_headers: vec![(
1576                            "content-type".to_string(),
1577                            "application/json".to_string(),
1578                        )],
1579                        expected_body_fields: vec![("name".to_string(), "string".to_string())],
1580                    },
1581                    200,
1582                    &headers,
1583                    r#"{"name": "test"}"#,
1584                )
1585                .0
1586        );
1587
1588        // Wrong status
1589        assert!(
1590            !executor
1591                .validate_response(
1592                    &CheckValidation::Custom {
1593                        expected_status: 200,
1594                        expected_headers: vec![],
1595                        expected_body_fields: vec![],
1596                    },
1597                    404,
1598                    &headers,
1599                    "",
1600                )
1601                .0
1602        );
1603    }
1604
1605    #[test]
1606    fn test_aggregate_results() {
1607        let results = vec![
1608            CheckResult {
1609                name: "check1".to_string(),
1610                passed: true,
1611                failure_detail: None,
1612                captured: None,
1613            },
1614            CheckResult {
1615                name: "check2".to_string(),
1616                passed: false,
1617                captured: None,
1618                failure_detail: Some(FailureDetail {
1619                    check: "check2".to_string(),
1620                    request: FailureRequest {
1621                        method: "GET".to_string(),
1622                        url: "http://example.com".to_string(),
1623                        headers: HashMap::new(),
1624                        body: String::new(),
1625                    },
1626                    response: FailureResponse {
1627                        status: 500,
1628                        headers: HashMap::new(),
1629                        body: "error".to_string(),
1630                    },
1631                    expected: "status >= 200 && status < 500".to_string(),
1632                    schema_violations: Vec::new(),
1633                }),
1634            },
1635        ];
1636
1637        let report = NativeConformanceExecutor::aggregate(results);
1638        let raw = report.raw_check_results();
1639        assert_eq!(raw.get("check1"), Some(&(1, 0)));
1640        assert_eq!(raw.get("check2"), Some(&(0, 1)));
1641    }
1642
1643    #[test]
1644    fn test_custom_check_building() {
1645        let config = ConformanceConfig {
1646            target_url: "http://localhost:3000".to_string(),
1647            ..Default::default()
1648        };
1649        let mut executor = NativeConformanceExecutor::new(config).unwrap();
1650
1651        let custom = CustomCheck {
1652            name: "custom:test-get".to_string(),
1653            path: "/api/test".to_string(),
1654            method: "GET".to_string(),
1655            expected_status: 200,
1656            body: None,
1657            expected_headers: std::collections::HashMap::new(),
1658            expected_body_fields: vec![],
1659            headers: std::collections::HashMap::new(),
1660        };
1661
1662        executor.add_custom_check(&custom);
1663        assert_eq!(executor.check_count(), 1);
1664        assert_eq!(executor.checks[0].name, "custom:test-get");
1665    }
1666
1667    #[test]
1668    fn test_openapi_schema_to_json_schema_object() {
1669        use openapiv3::{ObjectType, Schema, SchemaData, SchemaKind, Type};
1670
1671        let schema = Schema {
1672            schema_data: SchemaData::default(),
1673            schema_kind: SchemaKind::Type(Type::Object(ObjectType {
1674                required: vec!["name".to_string()],
1675                ..Default::default()
1676            })),
1677        };
1678
1679        let json = openapi_schema_to_json_schema(&schema);
1680        assert_eq!(json["type"], "object");
1681        assert_eq!(json["required"][0], "name");
1682    }
1683}