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