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(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        self.append_custom_checks(&custom_config)
533    }
534
535    /// Load custom checks from an already-parsed `CustomConformanceConfig`.
536    ///
537    /// Counterpart to [`Self::with_custom_checks`] for callers that
538    /// don't have a filesystem path — the cloud test-runner receives
539    /// the YAML inline on the suite config and parses it server-side
540    /// before reaching the executor, so it can't (and shouldn't) write
541    /// the bytes to a tempfile just to satisfy a file-based API.
542    pub fn with_custom_checks_from_config(
543        self,
544        custom_config: CustomConformanceConfig,
545    ) -> Result<Self> {
546        self.append_custom_checks(&custom_config)
547    }
548
549    /// Shared implementation: filter + add a parsed
550    /// `CustomConformanceConfig`'s entries onto the check list.
551    /// Pulled out of `with_custom_checks` so the inline-config and
552    /// file-based paths can't drift.
553    fn append_custom_checks(mut self, custom_config: &CustomConformanceConfig) -> Result<Self> {
554        let filter_re = match &self.config.custom_filter {
555            Some(pattern) => Some(regex::Regex::new(pattern).map_err(|e| {
556                BenchError::Other(format!("Invalid --conformance-custom-filter regex: {}", e))
557            })?),
558            None => None,
559        };
560
561        let mut included = 0usize;
562        let total = custom_config.custom_checks.len();
563        for check in &custom_config.custom_checks {
564            if let Some(ref re) = filter_re {
565                if !re.is_match(&check.name) && !re.is_match(&check.path) {
566                    continue;
567                }
568            }
569            self.add_custom_check(check);
570            included += 1;
571        }
572
573        if filter_re.is_some() {
574            tracing::info!("Custom check filter: {}/{} checks matched pattern", included, total);
575        }
576
577        Ok(self)
578    }
579
580    /// Return the number of checks that will be executed
581    pub fn check_count(&self) -> usize {
582        self.checks.len()
583    }
584
585    /// Execute all checks and return a `ConformanceReport`
586    pub async fn execute(&self) -> Result<ConformanceReport> {
587        let mut results = Vec::with_capacity(self.checks.len());
588        let delay = self.config.request_delay_ms;
589
590        for (i, check) in self.checks.iter().enumerate() {
591            if delay > 0 && i > 0 {
592                tokio::time::sleep(Duration::from_millis(delay)).await;
593            }
594            results.push(self.execute_check(check).await);
595        }
596
597        // Write request log if --export-requests was set
598        if self.config.export_requests {
599            if let Some(ref output_dir) = self.config.output_dir {
600                let request_log: Vec<_> = results
601                    .iter()
602                    .filter_map(|r| {
603                        r.captured.as_ref().map(|c| {
604                            serde_json::json!({
605                                "check": r.name,
606                                "passed": r.passed,
607                                "request": {
608                                    "method": c.method,
609                                    "url": c.url,
610                                    "headers": c.request_headers,
611                                    "body": c.request_body,
612                                },
613                                "response": {
614                                    "status": c.response_status,
615                                    "headers": c.response_headers,
616                                    "body": c.response_body,
617                                },
618                            })
619                        })
620                    })
621                    .collect();
622                let path = output_dir.join("conformance-requests.json");
623                if let Ok(json) = serde_json::to_string_pretty(&request_log) {
624                    let _ = std::fs::write(&path, json);
625                    tracing::info!(
626                        "Exported {} request/response pairs to {}",
627                        request_log.len(),
628                        path.display()
629                    );
630                }
631            }
632        }
633
634        Ok(Self::aggregate(results))
635    }
636
637    /// Execute all checks with progress events sent to the channel
638    pub async fn execute_with_progress(
639        &self,
640        tx: mpsc::Sender<ConformanceProgress>,
641    ) -> Result<ConformanceReport> {
642        let total = self.checks.len();
643        let delay = self.config.request_delay_ms;
644        let _ = tx
645            .send(ConformanceProgress::Started {
646                total_checks: total,
647            })
648            .await;
649
650        let mut results = Vec::with_capacity(total);
651
652        for (i, check) in self.checks.iter().enumerate() {
653            if delay > 0 && i > 0 {
654                tokio::time::sleep(Duration::from_millis(delay)).await;
655            }
656            let result = self.execute_check(check).await;
657            let passed = result.passed;
658            let name = result.name.clone();
659            results.push(result);
660
661            let _ = tx
662                .send(ConformanceProgress::CheckCompleted {
663                    name,
664                    passed,
665                    checks_done: i + 1,
666                })
667                .await;
668        }
669
670        let _ = tx.send(ConformanceProgress::Finished).await;
671        Ok(Self::aggregate(results))
672    }
673
674    /// Execute a single check
675    async fn execute_check(&self, check: &ConformanceCheck) -> CheckResult {
676        let base_url = self.config.effective_base_url();
677        let url = format!("{}{}", base_url.trim_end_matches('/'), check.path);
678
679        let mut request = self.client.request(check.method.clone(), &url);
680
681        // Add headers
682        for (name, value) in &check.headers {
683            request = request.header(name.as_str(), value.as_str());
684        }
685
686        // Add body
687        match &check.body {
688            Some(CheckBody::Json(value)) => {
689                request = request.json(value);
690            }
691            Some(CheckBody::FormUrlencoded(fields)) => {
692                request = request.form(fields);
693            }
694            Some(CheckBody::Raw {
695                content,
696                content_type,
697            }) => {
698                // For multipart, use the multipart API
699                if content_type == "text/plain" && check.path.contains("multipart") {
700                    let part = reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
701                        .file_name("test.txt")
702                        .mime_str(content_type)
703                        .unwrap_or_else(|_| {
704                            reqwest::multipart::Part::bytes(content.as_bytes().to_vec())
705                        });
706                    let form = reqwest::multipart::Form::new().part("field", part);
707                    request = request.multipart(form);
708                } else {
709                    request =
710                        request.header("Content-Type", content_type.as_str()).body(content.clone());
711                }
712            }
713            None => {}
714        }
715
716        let req_body_str = match &check.body {
717            Some(CheckBody::Json(v)) => v.to_string(),
718            Some(CheckBody::FormUrlencoded(f)) => {
719                f.iter().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join("&")
720            }
721            Some(CheckBody::Raw { content, .. }) => content.clone(),
722            None => String::new(),
723        };
724
725        let response = match request.send().await {
726            Ok(resp) => resp,
727            Err(e) => {
728                return CheckResult {
729                    name: check.name.clone(),
730                    passed: false,
731                    failure_detail: Some(FailureDetail {
732                        check: check.name.clone(),
733                        request: FailureRequest {
734                            method: check.method.to_string(),
735                            url: url.clone(),
736                            headers: HashMap::new(),
737                            body: String::new(),
738                        },
739                        response: FailureResponse {
740                            status: 0,
741                            headers: HashMap::new(),
742                            body: format!("Request failed: {}", e),
743                        },
744                        expected: format!("{:?}", check.validation),
745                        schema_violations: Vec::new(),
746                    }),
747                    captured: None,
748                };
749            }
750        };
751
752        let status = response.status().as_u16();
753        let resp_headers: HashMap<String, String> = response
754            .headers()
755            .iter()
756            .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
757            .collect();
758        let resp_body = response.text().await.unwrap_or_default();
759
760        let (passed, schema_violations) =
761            self.validate_response(&check.validation, status, &resp_headers, &resp_body);
762
763        // Always capture the exchange when export_requests is enabled
764        let captured = if self.config.export_requests {
765            Some(CapturedExchange {
766                method: check.method.to_string(),
767                url: url.clone(),
768                request_headers: check.headers.iter().cloned().collect(),
769                request_body: req_body_str,
770                response_status: status,
771                response_headers: resp_headers.clone(),
772                response_body: if resp_body.len() > 2000 {
773                    format!("{}...(truncated)", &resp_body[..2000])
774                } else {
775                    resp_body.clone()
776                },
777            })
778        } else {
779            None
780        };
781
782        let failure_detail = if !passed {
783            Some(FailureDetail {
784                check: check.name.clone(),
785                request: FailureRequest {
786                    method: check.method.to_string(),
787                    url,
788                    headers: check.headers.iter().cloned().collect(),
789                    body: match &check.body {
790                        Some(CheckBody::Json(v)) => v.to_string(),
791                        Some(CheckBody::FormUrlencoded(f)) => f
792                            .iter()
793                            .map(|(k, v)| format!("{}={}", k, v))
794                            .collect::<Vec<_>>()
795                            .join("&"),
796                        Some(CheckBody::Raw { content, .. }) => content.clone(),
797                        None => String::new(),
798                    },
799                },
800                response: FailureResponse {
801                    status,
802                    headers: resp_headers,
803                    body: if resp_body.len() > 500 {
804                        format!("{}...", &resp_body[..500])
805                    } else {
806                        resp_body
807                    },
808                },
809                expected: Self::describe_validation(&check.validation),
810                schema_violations,
811            })
812        } else {
813            None
814        };
815
816        CheckResult {
817            name: check.name.clone(),
818            passed,
819            failure_detail,
820            captured,
821        }
822    }
823
824    /// Validate a response against the check's validation rules.
825    ///
826    /// Returns `(passed, schema_violations)` where `schema_violations` contains
827    /// field-level details when a `SchemaValidation` check fails.
828    fn validate_response(
829        &self,
830        validation: &CheckValidation,
831        status: u16,
832        headers: &HashMap<String, String>,
833        body: &str,
834    ) -> (bool, Vec<SchemaViolation>) {
835        match validation {
836            CheckValidation::StatusRange { min, max_exclusive } => {
837                (status >= *min && status < *max_exclusive, Vec::new())
838            }
839            CheckValidation::ExactStatus(expected) => (status == *expected, Vec::new()),
840            CheckValidation::SchemaValidation {
841                status_min,
842                status_max,
843                schema,
844            } => {
845                if status < *status_min || status >= *status_max {
846                    return (false, Vec::new());
847                }
848                // Parse body as JSON and validate against schema
849                let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
850                    return (
851                        false,
852                        vec![SchemaViolation {
853                            field_path: "/".to_string(),
854                            violation_type: "parse_error".to_string(),
855                            expected: "valid JSON".to_string(),
856                            actual: "non-JSON response body".to_string(),
857                        }],
858                    );
859                };
860                match jsonschema::validator_for(schema) {
861                    Ok(validator) => {
862                        let errors: Vec<_> = validator.iter_errors(&body_value).collect();
863                        if errors.is_empty() {
864                            (true, Vec::new())
865                        } else {
866                            let violations = errors
867                                .iter()
868                                .map(|err| {
869                                    let field_path = err.instance_path.to_string();
870                                    let field_path = if field_path.is_empty() {
871                                        "/".to_string()
872                                    } else {
873                                        field_path
874                                    };
875                                    SchemaViolation {
876                                        field_path,
877                                        violation_type: format!("{:?}", err.kind)
878                                            .split('(')
879                                            .next()
880                                            .unwrap_or("unknown")
881                                            .split('{')
882                                            .next()
883                                            .unwrap_or("unknown")
884                                            .split(' ')
885                                            .next()
886                                            .unwrap_or("unknown")
887                                            .trim()
888                                            .to_string(),
889                                        expected: {
890                                            // Extract human-readable expected value from the error.
891                                            // The schema_path is like "/properties/field/type" —
892                                            // extract the last meaningful segment instead.
893                                            let schema_str = format!("{}", err.schema_path);
894                                            match &err.kind {
895                                                jsonschema::error::ValidationErrorKind::Type { kind } => {
896                                                    format!("type: {:?}", kind)
897                                                }
898                                                jsonschema::error::ValidationErrorKind::Required { property } => {
899                                                    format!("required field: {}", property)
900                                                }
901                                                _ => {
902                                                    // For other kinds, use last path segment
903                                                    schema_str
904                                                        .rsplit('/')
905                                                        .next()
906                                                        .unwrap_or(&schema_str)
907                                                        .to_string()
908                                                }
909                                            }
910                                        },
911                                        actual: format!("{}", err),
912                                    }
913                                })
914                                .collect();
915                            (false, violations)
916                        }
917                    }
918                    Err(_) => {
919                        // Schema compilation failed — fall back to is_valid behavior
920                        (
921                            false,
922                            vec![SchemaViolation {
923                                field_path: "/".to_string(),
924                                violation_type: "schema_compile_error".to_string(),
925                                expected: "valid JSON schema".to_string(),
926                                actual: "schema failed to compile".to_string(),
927                            }],
928                        )
929                    }
930                }
931            }
932            CheckValidation::Custom {
933                expected_status,
934                expected_headers,
935                expected_body_fields,
936            } => {
937                if status != *expected_status {
938                    return (false, Vec::new());
939                }
940                // Check headers with regex
941                for (header_name, pattern) in expected_headers {
942                    let header_val = headers
943                        .get(header_name)
944                        .or_else(|| headers.get(&header_name.to_lowercase()))
945                        .map(|s| s.as_str())
946                        .unwrap_or("");
947                    if let Ok(re) = regex::Regex::new(pattern) {
948                        if !re.is_match(header_val) {
949                            return (false, Vec::new());
950                        }
951                    }
952                }
953                // Check body field types
954                if !expected_body_fields.is_empty() {
955                    let Ok(body_value) = serde_json::from_str::<serde_json::Value>(body) else {
956                        return (false, Vec::new());
957                    };
958                    for (field_name, field_type) in expected_body_fields {
959                        let field = &body_value[field_name];
960                        let ok = match field_type.as_str() {
961                            "string" => field.is_string(),
962                            "integer" => field.is_i64() || field.is_u64(),
963                            "number" => field.is_number(),
964                            "boolean" => field.is_boolean(),
965                            "array" => field.is_array(),
966                            "object" => field.is_object(),
967                            _ => !field.is_null(),
968                        };
969                        if !ok {
970                            return (false, Vec::new());
971                        }
972                    }
973                }
974                (true, Vec::new())
975            }
976        }
977    }
978
979    /// Human-readable validation description for failure reports
980    fn describe_validation(validation: &CheckValidation) -> String {
981        match validation {
982            CheckValidation::StatusRange { min, max_exclusive } => {
983                format!("status >= {} && status < {}", min, max_exclusive)
984            }
985            CheckValidation::ExactStatus(code) => format!("status === {}", code),
986            CheckValidation::SchemaValidation {
987                status_min,
988                status_max,
989                ..
990            } => {
991                format!("status >= {} && status < {} + schema validation", status_min, status_max)
992            }
993            CheckValidation::Custom {
994                expected_status, ..
995            } => {
996                format!("status === {}", expected_status)
997            }
998        }
999    }
1000
1001    /// Aggregate check results into a `ConformanceReport`
1002    fn aggregate(results: Vec<CheckResult>) -> ConformanceReport {
1003        let mut check_results: HashMap<String, (u64, u64)> = HashMap::new();
1004        let mut failure_details = Vec::new();
1005
1006        for result in results {
1007            let entry = check_results.entry(result.name.clone()).or_insert((0, 0));
1008            if result.passed {
1009                entry.0 += 1;
1010            } else {
1011                entry.1 += 1;
1012            }
1013            if let Some(detail) = result.failure_detail {
1014                failure_details.push(detail);
1015            }
1016        }
1017
1018        ConformanceReport::from_results(check_results, failure_details)
1019    }
1020
1021    // --- Helper methods ---
1022
1023    /// Build a spec-driven check from an annotated operation and feature
1024    fn build_spec_check(
1025        &self,
1026        check_name: &str,
1027        op: &AnnotatedOperation,
1028        feature: &ConformanceFeature,
1029    ) -> ConformanceCheck {
1030        // Build URL path with parameters substituted
1031        let mut url_path = op.path.clone();
1032        for (name, value) in &op.path_params {
1033            url_path = url_path.replace(&format!("{{{}}}", name), value);
1034        }
1035        // Append query params
1036        if !op.query_params.is_empty() {
1037            let qs: Vec<String> =
1038                op.query_params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
1039            url_path = format!("{}?{}", url_path, qs.join("&"));
1040        }
1041
1042        // Build effective headers
1043        let mut effective_headers = self.effective_headers(&op.header_params);
1044
1045        // For non-default response codes, add mock server header
1046        if matches!(feature, ConformanceFeature::Response400 | ConformanceFeature::Response404) {
1047            let code = match feature {
1048                ConformanceFeature::Response400 => "400",
1049                ConformanceFeature::Response404 => "404",
1050                _ => unreachable!(),
1051            };
1052            effective_headers.push(("X-Mockforge-Response-Status".to_string(), code.to_string()));
1053        }
1054
1055        // Inject auth headers for security checks or secured endpoints
1056        let needs_auth = matches!(
1057            feature,
1058            ConformanceFeature::SecurityBearer
1059                | ConformanceFeature::SecurityBasic
1060                | ConformanceFeature::SecurityApiKey
1061        ) || !op.security_schemes.is_empty();
1062
1063        if needs_auth {
1064            self.inject_security_headers(&op.security_schemes, &mut effective_headers);
1065        }
1066
1067        // Determine method
1068        let method = match op.method.as_str() {
1069            "GET" => Method::GET,
1070            "POST" => Method::POST,
1071            "PUT" => Method::PUT,
1072            "PATCH" => Method::PATCH,
1073            "DELETE" => Method::DELETE,
1074            "HEAD" => Method::HEAD,
1075            "OPTIONS" => Method::OPTIONS,
1076            _ => Method::GET,
1077        };
1078
1079        // Determine body
1080        let body = match method {
1081            Method::POST | Method::PUT | Method::PATCH => {
1082                if let Some(sample) = &op.sample_body {
1083                    // Add Content-Type if not present
1084                    let content_type =
1085                        op.request_body_content_type.as_deref().unwrap_or("application/json");
1086                    if !effective_headers
1087                        .iter()
1088                        .any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1089                    {
1090                        effective_headers
1091                            .push(("Content-Type".to_string(), content_type.to_string()));
1092                    }
1093                    match content_type {
1094                        "application/x-www-form-urlencoded" => {
1095                            // Parse as form fields
1096                            let fields: Vec<(String, String)> = serde_json::from_str::<
1097                                serde_json::Value,
1098                            >(
1099                                sample
1100                            )
1101                            .ok()
1102                            .and_then(|v| {
1103                                v.as_object().map(|obj| {
1104                                    obj.iter()
1105                                        .map(|(k, v)| {
1106                                            (k.clone(), v.as_str().unwrap_or("").to_string())
1107                                        })
1108                                        .collect()
1109                                })
1110                            })
1111                            .unwrap_or_default();
1112                            Some(CheckBody::FormUrlencoded(fields))
1113                        }
1114                        _ => {
1115                            // Try JSON, fall back to raw
1116                            match serde_json::from_str::<serde_json::Value>(sample) {
1117                                Ok(v) => Some(CheckBody::Json(v)),
1118                                Err(_) => Some(CheckBody::Raw {
1119                                    content: sample.clone(),
1120                                    content_type: content_type.to_string(),
1121                                }),
1122                            }
1123                        }
1124                    }
1125                } else {
1126                    None
1127                }
1128            }
1129            _ => None,
1130        };
1131
1132        // Determine validation
1133        let validation = self.determine_validation(feature, op);
1134
1135        ConformanceCheck {
1136            name: check_name.to_string(),
1137            method,
1138            path: url_path,
1139            headers: effective_headers,
1140            body,
1141            validation,
1142        }
1143    }
1144
1145    /// Determine validation strategy based on the conformance feature
1146    fn determine_validation(
1147        &self,
1148        feature: &ConformanceFeature,
1149        op: &AnnotatedOperation,
1150    ) -> CheckValidation {
1151        match feature {
1152            ConformanceFeature::Response200 => CheckValidation::ExactStatus(200),
1153            ConformanceFeature::Response201 => CheckValidation::ExactStatus(201),
1154            ConformanceFeature::Response204 => CheckValidation::ExactStatus(204),
1155            ConformanceFeature::Response400 => CheckValidation::ExactStatus(400),
1156            ConformanceFeature::Response404 => CheckValidation::ExactStatus(404),
1157            ConformanceFeature::SecurityBearer
1158            | ConformanceFeature::SecurityBasic
1159            | ConformanceFeature::SecurityApiKey => CheckValidation::StatusRange {
1160                min: 200,
1161                max_exclusive: 400,
1162            },
1163            ConformanceFeature::ResponseValidation => {
1164                if let Some(schema) = &op.response_schema {
1165                    // Convert openapiv3 Schema to JSON Schema value for jsonschema crate
1166                    let schema_json = openapi_schema_to_json_schema(schema);
1167                    CheckValidation::SchemaValidation {
1168                        status_min: 200,
1169                        status_max: 500,
1170                        schema: schema_json,
1171                    }
1172                } else {
1173                    CheckValidation::StatusRange {
1174                        min: 200,
1175                        max_exclusive: 500,
1176                    }
1177                }
1178            }
1179            _ => CheckValidation::StatusRange {
1180                min: 200,
1181                max_exclusive: 500,
1182            },
1183        }
1184    }
1185
1186    /// Add a simple GET reference check with default status range validation
1187    fn add_ref_get(&mut self, name: &str, path: &str) {
1188        self.checks.push(ConformanceCheck {
1189            name: name.to_string(),
1190            method: Method::GET,
1191            path: path.to_string(),
1192            headers: self.custom_headers_only(),
1193            body: None,
1194            validation: CheckValidation::StatusRange {
1195                min: 200,
1196                max_exclusive: 500,
1197            },
1198        });
1199    }
1200
1201    /// Merge spec-derived headers with custom headers (custom overrides spec)
1202    fn effective_headers(&self, spec_headers: &[(String, String)]) -> Vec<(String, String)> {
1203        let mut headers = Vec::new();
1204        for (k, v) in spec_headers {
1205            // Skip if custom headers override this one
1206            if self.config.custom_headers.iter().any(|(ck, _)| ck.eq_ignore_ascii_case(k)) {
1207                continue;
1208            }
1209            headers.push((k.clone(), v.clone()));
1210        }
1211        // Append custom headers
1212        headers.extend(self.config.custom_headers.clone());
1213        headers
1214    }
1215
1216    /// Merge provided headers with custom headers
1217    fn merge_headers(&self, mut headers: Vec<(String, String)>) -> Vec<(String, String)> {
1218        for (k, v) in &self.config.custom_headers {
1219            if !headers.iter().any(|(hk, _)| hk.eq_ignore_ascii_case(k)) {
1220                headers.push((k.clone(), v.clone()));
1221            }
1222        }
1223        headers
1224    }
1225
1226    /// Return only custom headers (for checks that don't have spec-derived headers)
1227    fn custom_headers_only(&self) -> Vec<(String, String)> {
1228        self.config.custom_headers.clone()
1229    }
1230
1231    /// Inject security headers based on resolved security schemes.
1232    /// If the user provides a Cookie header via --conformance-header, skip automatic
1233    /// Authorization headers (Bearer/Basic) since the user manages their own auth.
1234    fn inject_security_headers(
1235        &self,
1236        schemes: &[SecuritySchemeInfo],
1237        headers: &mut Vec<(String, String)>,
1238    ) {
1239        // If user provides Cookie header, they're using session-based auth — skip auto auth
1240        let has_cookie_auth =
1241            self.config.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("Cookie"));
1242        let mut to_add: Vec<(String, String)> = Vec::new();
1243
1244        for scheme in schemes {
1245            match scheme {
1246                SecuritySchemeInfo::Bearer => {
1247                    if !has_cookie_auth
1248                        && !Self::header_present(
1249                            "Authorization",
1250                            headers,
1251                            &self.config.custom_headers,
1252                        )
1253                    {
1254                        to_add.push((
1255                            "Authorization".to_string(),
1256                            "Bearer mockforge-conformance-test-token".to_string(),
1257                        ));
1258                    }
1259                }
1260                SecuritySchemeInfo::Basic => {
1261                    if !has_cookie_auth
1262                        && !Self::header_present(
1263                            "Authorization",
1264                            headers,
1265                            &self.config.custom_headers,
1266                        )
1267                    {
1268                        let creds = self.config.basic_auth.as_deref().unwrap_or("test:test");
1269                        use base64::Engine;
1270                        let encoded =
1271                            base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
1272                        to_add.push(("Authorization".to_string(), format!("Basic {}", encoded)));
1273                    }
1274                }
1275                SecuritySchemeInfo::ApiKey { location, name } => match location {
1276                    ApiKeyLocation::Header => {
1277                        if !Self::header_present(name, headers, &self.config.custom_headers) {
1278                            let key = self
1279                                .config
1280                                .api_key
1281                                .as_deref()
1282                                .unwrap_or("mockforge-conformance-test-key");
1283                            to_add.push((name.clone(), key.to_string()));
1284                        }
1285                    }
1286                    ApiKeyLocation::Cookie => {
1287                        if !Self::header_present("Cookie", headers, &self.config.custom_headers) {
1288                            to_add.push((
1289                                "Cookie".to_string(),
1290                                format!("{}=mockforge-conformance-test-session", name),
1291                            ));
1292                        }
1293                    }
1294                    ApiKeyLocation::Query => {
1295                        // Handled in URL, not headers
1296                    }
1297                },
1298            }
1299        }
1300
1301        headers.extend(to_add);
1302    }
1303
1304    /// Check if a header name is present in either the existing headers or custom headers
1305    fn header_present(
1306        name: &str,
1307        headers: &[(String, String)],
1308        custom_headers: &[(String, String)],
1309    ) -> bool {
1310        headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1311            || custom_headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name))
1312    }
1313
1314    /// Add a custom check from YAML config
1315    fn add_custom_check(&mut self, check: &CustomCheck) {
1316        let method = match check.method.to_uppercase().as_str() {
1317            "GET" => Method::GET,
1318            "POST" => Method::POST,
1319            "PUT" => Method::PUT,
1320            "PATCH" => Method::PATCH,
1321            "DELETE" => Method::DELETE,
1322            "HEAD" => Method::HEAD,
1323            "OPTIONS" => Method::OPTIONS,
1324            _ => Method::GET,
1325        };
1326
1327        // Build headers
1328        let mut headers: Vec<(String, String)> =
1329            check.headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1330        // Add global custom headers (check-specific take priority)
1331        for (k, v) in &self.config.custom_headers {
1332            if !check.headers.contains_key(k) {
1333                headers.push((k.clone(), v.clone()));
1334            }
1335        }
1336        // Add Content-Type for JSON body if not present
1337        if check.body.is_some()
1338            && !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
1339        {
1340            headers.push(("Content-Type".to_string(), "application/json".to_string()));
1341        }
1342
1343        // Body
1344        let body = check
1345            .body
1346            .as_ref()
1347            .and_then(|b| serde_json::from_str::<serde_json::Value>(b).ok().map(CheckBody::Json));
1348
1349        // Build expected headers for validation
1350        let expected_headers: Vec<(String, String)> =
1351            check.expected_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1352
1353        // Build expected body fields
1354        let expected_body_fields: Vec<(String, String)> = check
1355            .expected_body_fields
1356            .iter()
1357            .map(|f| (f.name.clone(), f.field_type.clone()))
1358            .collect();
1359
1360        // Primary status check
1361        self.checks.push(ConformanceCheck {
1362            name: check.name.clone(),
1363            method,
1364            path: check.path.clone(),
1365            headers,
1366            body,
1367            validation: CheckValidation::Custom {
1368                expected_status: check.expected_status,
1369                expected_headers,
1370                expected_body_fields,
1371            },
1372        });
1373    }
1374}
1375
1376/// Convert an `openapiv3::Schema` to a JSON Schema `serde_json::Value`
1377/// suitable for use with the `jsonschema` crate.
1378fn openapi_schema_to_json_schema(schema: &openapiv3::Schema) -> serde_json::Value {
1379    use openapiv3::{SchemaKind, Type};
1380
1381    match &schema.schema_kind {
1382        SchemaKind::Type(Type::Object(obj)) => {
1383            let mut props = serde_json::Map::new();
1384            for (name, prop_ref) in &obj.properties {
1385                if let openapiv3::ReferenceOr::Item(prop_schema) = prop_ref {
1386                    props.insert(name.clone(), openapi_schema_to_json_schema(prop_schema));
1387                }
1388            }
1389            let mut schema_obj = serde_json::json!({
1390                "type": "object",
1391                "properties": props,
1392            });
1393            if !obj.required.is_empty() {
1394                schema_obj["required"] = serde_json::Value::Array(
1395                    obj.required.iter().map(|s| serde_json::json!(s)).collect(),
1396                );
1397            }
1398            schema_obj
1399        }
1400        SchemaKind::Type(Type::Array(arr)) => {
1401            let mut schema_obj = serde_json::json!({"type": "array"});
1402            if let Some(openapiv3::ReferenceOr::Item(item_schema)) = &arr.items {
1403                schema_obj["items"] = openapi_schema_to_json_schema(item_schema);
1404            }
1405            schema_obj
1406        }
1407        SchemaKind::Type(Type::String(s)) => {
1408            let mut obj = serde_json::json!({"type": "string"});
1409            if let Some(min) = s.min_length {
1410                obj["minLength"] = serde_json::json!(min);
1411            }
1412            if let Some(max) = s.max_length {
1413                obj["maxLength"] = serde_json::json!(max);
1414            }
1415            if let Some(pattern) = &s.pattern {
1416                obj["pattern"] = serde_json::json!(pattern);
1417            }
1418            if !s.enumeration.is_empty() {
1419                obj["enum"] = serde_json::Value::Array(
1420                    s.enumeration
1421                        .iter()
1422                        .filter_map(|v| v.as_ref().map(|s| serde_json::json!(s)))
1423                        .collect(),
1424                );
1425            }
1426            obj
1427        }
1428        SchemaKind::Type(Type::Integer(_)) => serde_json::json!({"type": "integer"}),
1429        SchemaKind::Type(Type::Number(_)) => serde_json::json!({"type": "number"}),
1430        SchemaKind::Type(Type::Boolean(_)) => serde_json::json!({"type": "boolean"}),
1431        _ => serde_json::json!({}),
1432    }
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437    use super::*;
1438
1439    #[test]
1440    fn test_reference_check_count() {
1441        let config = ConformanceConfig {
1442            target_url: "http://localhost:3000".to_string(),
1443            ..Default::default()
1444        };
1445        let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1446        // 7 params + 3 bodies + 6 schema + 3 composition + 7 formats + 5 constraints
1447        // + 5 response codes + 7 methods + 1 content + 3 security = 47
1448        assert_eq!(executor.check_count(), 47);
1449    }
1450
1451    #[test]
1452    fn with_custom_checks_from_config_appends() {
1453        // Construct an inline CustomConformanceConfig and verify both
1454        // that the checks are appended on top of reference-checks
1455        // mode and that the filter regex (when present) drops
1456        // non-matching entries.
1457        let custom_yaml = r#"
1458custom_checks:
1459  - name: "custom:health"
1460    path: /health
1461    method: GET
1462    expected_status: 200
1463  - name: "custom:create"
1464    path: /widgets
1465    method: POST
1466    expected_status: 201
1467"#;
1468        let parsed: CustomConformanceConfig =
1469            serde_yaml::from_str(custom_yaml).expect("YAML parses");
1470        assert_eq!(parsed.custom_checks.len(), 2);
1471
1472        let base = ConformanceConfig {
1473            target_url: "http://localhost:3000".to_string(),
1474            ..Default::default()
1475        };
1476        let executor = NativeConformanceExecutor::new(base)
1477            .unwrap()
1478            .with_reference_checks()
1479            .with_custom_checks_from_config(parsed)
1480            .expect("custom checks load");
1481        // 47 reference checks + 2 custom = 49.
1482        assert_eq!(executor.check_count(), 49);
1483    }
1484
1485    #[test]
1486    fn with_custom_checks_from_config_respects_filter() {
1487        // custom_filter is regex-based; only matching entries should
1488        // make it onto the executor.
1489        let custom_yaml = r#"
1490custom_checks:
1491  - name: "custom:health"
1492    path: /health
1493    method: GET
1494    expected_status: 200
1495  - name: "custom:create-widget"
1496    path: /widgets
1497    method: POST
1498    expected_status: 201
1499"#;
1500        let parsed: CustomConformanceConfig =
1501            serde_yaml::from_str(custom_yaml).expect("YAML parses");
1502
1503        let base = ConformanceConfig {
1504            target_url: "http://localhost:3000".to_string(),
1505            // Reference checks would add 47; turn them off so the
1506            // count is purely the custom set after filtering.
1507            categories: Some(vec!["no_such_category".to_string()]),
1508            custom_filter: Some("health".to_string()),
1509            ..Default::default()
1510        };
1511        let executor = NativeConformanceExecutor::new(base)
1512            .unwrap()
1513            .with_reference_checks()
1514            .with_custom_checks_from_config(parsed)
1515            .expect("custom checks load");
1516        // categories filter drops all reference checks; custom_filter
1517        // keeps the one entry whose name matches /health/.
1518        assert_eq!(executor.check_count(), 1);
1519    }
1520
1521    #[test]
1522    fn with_custom_checks_from_config_rejects_bad_filter_regex() {
1523        let parsed: CustomConformanceConfig =
1524            serde_yaml::from_str("custom_checks: []").expect("YAML parses");
1525        let base = ConformanceConfig {
1526            target_url: "http://localhost:3000".to_string(),
1527            custom_filter: Some("[unclosed".to_string()),
1528            ..Default::default()
1529        };
1530        let result = NativeConformanceExecutor::new(base)
1531            .unwrap()
1532            .with_reference_checks()
1533            .with_custom_checks_from_config(parsed);
1534        assert!(result.is_err(), "bad regex should bubble up as BenchError");
1535    }
1536
1537    #[test]
1538    fn test_reference_checks_with_category_filter() {
1539        let config = ConformanceConfig {
1540            target_url: "http://localhost:3000".to_string(),
1541            categories: Some(vec!["Parameters".to_string()]),
1542            ..Default::default()
1543        };
1544        let executor = NativeConformanceExecutor::new(config).unwrap().with_reference_checks();
1545        assert_eq!(executor.check_count(), 7);
1546    }
1547
1548    #[test]
1549    fn test_validate_status_range() {
1550        let config = ConformanceConfig {
1551            target_url: "http://localhost:3000".to_string(),
1552            ..Default::default()
1553        };
1554        let executor = NativeConformanceExecutor::new(config).unwrap();
1555        let headers = HashMap::new();
1556
1557        assert!(
1558            executor
1559                .validate_response(
1560                    &CheckValidation::StatusRange {
1561                        min: 200,
1562                        max_exclusive: 500,
1563                    },
1564                    200,
1565                    &headers,
1566                    "",
1567                )
1568                .0
1569        );
1570        assert!(
1571            executor
1572                .validate_response(
1573                    &CheckValidation::StatusRange {
1574                        min: 200,
1575                        max_exclusive: 500,
1576                    },
1577                    404,
1578                    &headers,
1579                    "",
1580                )
1581                .0
1582        );
1583        assert!(
1584            !executor
1585                .validate_response(
1586                    &CheckValidation::StatusRange {
1587                        min: 200,
1588                        max_exclusive: 500,
1589                    },
1590                    500,
1591                    &headers,
1592                    "",
1593                )
1594                .0
1595        );
1596    }
1597
1598    #[test]
1599    fn test_validate_exact_status() {
1600        let config = ConformanceConfig {
1601            target_url: "http://localhost:3000".to_string(),
1602            ..Default::default()
1603        };
1604        let executor = NativeConformanceExecutor::new(config).unwrap();
1605        let headers = HashMap::new();
1606
1607        assert!(
1608            executor
1609                .validate_response(&CheckValidation::ExactStatus(200), 200, &headers, "")
1610                .0
1611        );
1612        assert!(
1613            !executor
1614                .validate_response(&CheckValidation::ExactStatus(200), 201, &headers, "")
1615                .0
1616        );
1617    }
1618
1619    #[test]
1620    fn test_validate_schema() {
1621        let config = ConformanceConfig {
1622            target_url: "http://localhost:3000".to_string(),
1623            ..Default::default()
1624        };
1625        let executor = NativeConformanceExecutor::new(config).unwrap();
1626        let headers = HashMap::new();
1627
1628        let schema = serde_json::json!({
1629            "type": "object",
1630            "properties": {
1631                "name": {"type": "string"},
1632                "age": {"type": "integer"}
1633            },
1634            "required": ["name"]
1635        });
1636
1637        let (passed, violations) = executor.validate_response(
1638            &CheckValidation::SchemaValidation {
1639                status_min: 200,
1640                status_max: 300,
1641                schema: schema.clone(),
1642            },
1643            200,
1644            &headers,
1645            r#"{"name": "test", "age": 25}"#,
1646        );
1647        assert!(passed);
1648        assert!(violations.is_empty());
1649
1650        // Missing required field
1651        let (passed, violations) = executor.validate_response(
1652            &CheckValidation::SchemaValidation {
1653                status_min: 200,
1654                status_max: 300,
1655                schema: schema.clone(),
1656            },
1657            200,
1658            &headers,
1659            r#"{"age": 25}"#,
1660        );
1661        assert!(!passed);
1662        assert!(!violations.is_empty());
1663        assert_eq!(violations[0].violation_type, "Required");
1664    }
1665
1666    #[test]
1667    fn test_validate_custom() {
1668        let config = ConformanceConfig {
1669            target_url: "http://localhost:3000".to_string(),
1670            ..Default::default()
1671        };
1672        let executor = NativeConformanceExecutor::new(config).unwrap();
1673        let mut headers = HashMap::new();
1674        headers.insert("content-type".to_string(), "application/json".to_string());
1675
1676        assert!(
1677            executor
1678                .validate_response(
1679                    &CheckValidation::Custom {
1680                        expected_status: 200,
1681                        expected_headers: vec![(
1682                            "content-type".to_string(),
1683                            "application/json".to_string(),
1684                        )],
1685                        expected_body_fields: vec![("name".to_string(), "string".to_string())],
1686                    },
1687                    200,
1688                    &headers,
1689                    r#"{"name": "test"}"#,
1690                )
1691                .0
1692        );
1693
1694        // Wrong status
1695        assert!(
1696            !executor
1697                .validate_response(
1698                    &CheckValidation::Custom {
1699                        expected_status: 200,
1700                        expected_headers: vec![],
1701                        expected_body_fields: vec![],
1702                    },
1703                    404,
1704                    &headers,
1705                    "",
1706                )
1707                .0
1708        );
1709    }
1710
1711    #[test]
1712    fn test_aggregate_results() {
1713        let results = vec![
1714            CheckResult {
1715                name: "check1".to_string(),
1716                passed: true,
1717                failure_detail: None,
1718                captured: None,
1719            },
1720            CheckResult {
1721                name: "check2".to_string(),
1722                passed: false,
1723                captured: None,
1724                failure_detail: Some(FailureDetail {
1725                    check: "check2".to_string(),
1726                    request: FailureRequest {
1727                        method: "GET".to_string(),
1728                        url: "http://example.com".to_string(),
1729                        headers: HashMap::new(),
1730                        body: String::new(),
1731                    },
1732                    response: FailureResponse {
1733                        status: 500,
1734                        headers: HashMap::new(),
1735                        body: "error".to_string(),
1736                    },
1737                    expected: "status >= 200 && status < 500".to_string(),
1738                    schema_violations: Vec::new(),
1739                }),
1740            },
1741        ];
1742
1743        let report = NativeConformanceExecutor::aggregate(results);
1744        let raw = report.raw_check_results();
1745        assert_eq!(raw.get("check1"), Some(&(1, 0)));
1746        assert_eq!(raw.get("check2"), Some(&(0, 1)));
1747    }
1748
1749    #[test]
1750    fn test_custom_check_building() {
1751        let config = ConformanceConfig {
1752            target_url: "http://localhost:3000".to_string(),
1753            ..Default::default()
1754        };
1755        let mut executor = NativeConformanceExecutor::new(config).unwrap();
1756
1757        let custom = CustomCheck {
1758            name: "custom:test-get".to_string(),
1759            path: "/api/test".to_string(),
1760            method: "GET".to_string(),
1761            expected_status: 200,
1762            body: None,
1763            expected_headers: HashMap::new(),
1764            expected_body_fields: vec![],
1765            headers: HashMap::new(),
1766        };
1767
1768        executor.add_custom_check(&custom);
1769        assert_eq!(executor.check_count(), 1);
1770        assert_eq!(executor.checks[0].name, "custom:test-get");
1771    }
1772
1773    #[test]
1774    fn test_openapi_schema_to_json_schema_object() {
1775        use openapiv3::{ObjectType, Schema, SchemaData, SchemaKind, Type};
1776
1777        let schema = Schema {
1778            schema_data: SchemaData::default(),
1779            schema_kind: SchemaKind::Type(Type::Object(ObjectType {
1780                required: vec!["name".to_string()],
1781                ..Default::default()
1782            })),
1783        };
1784
1785        let json = openapi_schema_to_json_schema(&schema);
1786        assert_eq!(json["type"], "object");
1787        assert_eq!(json["required"][0], "name");
1788    }
1789}