Skip to main content

mockforge_bench/conformance/
generator.rs

1//! k6 script generator for OpenAPI 3.0.0 conformance testing
2
3use crate::error::{BenchError, Result};
4use std::path::{Path, PathBuf};
5
6use super::custom::CustomConformanceConfig;
7
8/// Configuration for conformance test generation
9#[derive(Default, Clone)]
10pub struct ConformanceConfig {
11    /// Target base URL
12    pub target_url: String,
13    /// API key for security scheme tests
14    pub api_key: Option<String>,
15    /// Basic auth credentials (user:pass) for security scheme tests
16    pub basic_auth: Option<String>,
17    /// Skip TLS verification
18    pub skip_tls_verify: bool,
19    /// Optional category filter — None means all categories
20    pub categories: Option<Vec<String>>,
21    /// Optional base path prefix for all generated URLs (e.g., "/api")
22    pub base_path: Option<String>,
23    /// Custom headers to inject into every conformance request (e.g., auth headers).
24    /// Each entry is (header_name, header_value). When a custom header matches
25    /// a spec-derived header name, the custom value replaces the placeholder.
26    pub custom_headers: Vec<(String, String)>,
27    /// Output directory for the conformance report (absolute path).
28    /// Used to write `conformance-report.json` to a deterministic location
29    /// so the CLI can find it after k6 execution.
30    pub output_dir: Option<PathBuf>,
31    /// When true, test ALL operations for method/response/body categories
32    /// instead of just one representative per feature check name.
33    pub all_operations: bool,
34    /// Optional path to a YAML file with custom conformance checks
35    pub custom_checks_file: Option<PathBuf>,
36    /// Delay in milliseconds between consecutive conformance requests.
37    /// Useful when testing against rate-limited APIs. Default: 0 (no delay).
38    pub request_delay_ms: u64,
39    /// Optional regex to filter custom checks by name or path.
40    /// Only checks whose name or path matches the regex are included.
41    pub custom_filter: Option<String>,
42    /// When true, export all request/response pairs to a JSON file
43    /// in the output directory (`conformance-requests.json`).
44    pub export_requests: bool,
45    /// When true, validate each request against the OpenAPI spec before
46    /// sending and report violations to `conformance-request-violations.json`.
47    pub validate_requests: bool,
48}
49
50impl ConformanceConfig {
51    /// Check if a category should be included based on the filter
52    pub fn should_include_category(&self, category: &str) -> bool {
53        match &self.categories {
54            None => true,
55            Some(cats) => cats.iter().any(|c| c.eq_ignore_ascii_case(category)),
56        }
57    }
58
59    /// Returns true if custom headers are configured
60    pub fn has_custom_headers(&self) -> bool {
61        !self.custom_headers.is_empty()
62    }
63
64    /// Returns true if custom headers contain a Cookie header.
65    /// When true, k6's automatic cookie jar should be disabled to prevent
66    /// duplicate cookies on subsequent requests.
67    pub fn has_cookie_header(&self) -> bool {
68        self.custom_headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("cookie"))
69    }
70
71    /// Format custom headers as a JS object literal string
72    pub fn custom_headers_js_object(&self) -> String {
73        let entries: Vec<String> = self
74            .custom_headers
75            .iter()
76            .map(|(k, v)| format!("'{}': '{}'", k, v.replace('\'', "\\'")))
77            .collect();
78        format!("{{ {} }}", entries.join(", "))
79    }
80
81    /// Generate a k6 group block for custom checks, if configured.
82    /// Returns `Ok(None)` if no custom checks file is configured.
83    /// Respects `custom_filter` to include only matching checks.
84    ///
85    /// Round 39 (#79) — returns BOTH the init-scope code (e.g.
86    /// `const __file_0 = open('/path', 'b')` for file uploads) and
87    /// the group body. The caller splices `init_code` near the top of
88    /// the script (after `BASE_URL`, before `export default
89    /// function`) and `group_body` inside the default function. k6
90    /// requires `open()` to live at init scope.
91    pub fn generate_custom_group(
92        &self,
93    ) -> Result<Option<crate::conformance::custom::K6CustomEmit>> {
94        let path = match &self.custom_checks_file {
95            Some(p) => p,
96            None => return Ok(None),
97        };
98        let mut config = CustomConformanceConfig::from_file(path)?;
99        if config.custom_checks.is_empty() {
100            return Ok(None);
101        }
102
103        // Apply regex filter if provided
104        if let Some(ref pattern) = self.custom_filter {
105            let re = regex::Regex::new(pattern).map_err(|e| {
106                BenchError::Other(format!("Invalid --conformance-custom-filter regex: {}", e))
107            })?;
108            let total = config.custom_checks.len();
109            config.custom_checks.retain(|c| re.is_match(&c.name) || re.is_match(&c.path));
110            tracing::info!(
111                "Custom check filter: {}/{} checks matched pattern",
112                config.custom_checks.len(),
113                total
114            );
115            if config.custom_checks.is_empty() {
116                return Ok(None);
117            }
118        }
119
120        Ok(Some(config.emit_k6_with_options(
121            "BASE_URL",
122            &self.custom_headers,
123            self.export_requests,
124        )))
125    }
126
127    /// Returns the effective base URL with base_path appended.
128    /// Handles trailing/leading slash normalization to avoid double slashes.
129    /// Always trims trailing slashes from the result so that `${BASE_URL}/path`
130    /// never produces `//path`.
131    pub fn effective_base_url(&self) -> String {
132        let base = match &self.base_path {
133            None => self.target_url.trim_end_matches('/').to_string(),
134            Some(bp) if bp.is_empty() => self.target_url.trim_end_matches('/').to_string(),
135            Some(bp) => {
136                let url = self.target_url.trim_end_matches('/');
137                let path = if bp.starts_with('/') {
138                    bp.as_str()
139                } else {
140                    return format!("{}/{}", url, bp).trim_end_matches('/').to_string();
141                };
142                format!("{}{}", url, path).trim_end_matches('/').to_string()
143            }
144        };
145        base
146    }
147}
148
149/// Generates k6 scripts for OpenAPI 3.0.0 conformance testing
150pub struct ConformanceGenerator {
151    config: ConformanceConfig,
152}
153
154impl ConformanceGenerator {
155    pub fn new(config: ConformanceConfig) -> Self {
156        Self { config }
157    }
158
159    /// Generate the conformance test k6 script
160    pub fn generate(&self) -> Result<String> {
161        let mut script = String::with_capacity(16384);
162
163        // Imports
164        script.push_str("import http from 'k6/http';\n");
165        script.push_str("import { check, group } from 'k6';\n");
166        if self.config.request_delay_ms > 0 {
167            script.push_str("import { sleep } from 'k6';\n");
168        }
169        script.push('\n');
170
171        // Tell k6 that all HTTP status codes are "expected" in conformance mode.
172        // Without this, k6 counts 4xx responses (e.g. intentional 404 tests) as
173        // http_req_failed errors, producing a misleading error rate percentage.
174        script.push_str(
175            "http.setResponseCallback(http.expectedStatuses({ min: 100, max: 599 }));\n\n",
176        );
177
178        // Options: 1 VU, 1 iteration (functional test, not load test)
179        script.push_str("export const options = {\n");
180        script.push_str("  vus: 1,\n");
181        script.push_str("  iterations: 1,\n");
182        if self.config.skip_tls_verify {
183            script.push_str("  insecureSkipTLSVerify: true,\n");
184        }
185        script.push_str("  thresholds: {\n");
186        script.push_str("    checks: ['rate>0'],\n");
187        script.push_str("  },\n");
188        script.push_str("};\n\n");
189
190        // Base URL (includes base_path if configured)
191        script.push_str(&format!("const BASE_URL = '{}';\n\n", self.config.effective_base_url()));
192
193        // Delay between requests (seconds) to avoid rate limiting
194        if self.config.request_delay_ms > 0 {
195            script.push_str(&format!(
196                "const REQUEST_DELAY = {:.3};\n\n",
197                self.config.request_delay_ms as f64 / 1000.0
198            ));
199        }
200
201        // Helper: JSON headers
202        script.push_str("const JSON_HEADERS = { 'Content-Type': 'application/json' };\n\n");
203
204        // Round 39 (#79) — emit init-scope code (e.g. `open()` calls
205        // for file uploads in custom checks) here, before any
206        // function declarations. k6 requires `open()` to live at
207        // script init scope; placing it inside `export default
208        // function` is a runtime ReferenceError.
209        let custom_emit = self.config.generate_custom_group()?;
210        if let Some(emit) = &custom_emit {
211            if !emit.init_code.is_empty() {
212                script.push_str("// Round 39 (#79) — preloaded upload bytes for custom checks\n");
213                script.push_str(&emit.init_code);
214                script.push('\n');
215            }
216        }
217
218        // Failure detail collector — logs req/res info for failed checks via console.log
219        script.push_str("function __captureFailure(checkName, res, expected) {\n");
220        script.push_str("  let bodyStr = '';\n");
221        script.push_str("  try { if (res.body) { const __n = res.body.length; bodyStr = res.body.substring(0, 65536); if (__n > 65536) bodyStr = bodyStr + ' <truncated at 65536 bytes; full body was ' + __n + ' bytes>'; } else { bodyStr = ''; } } catch(e) { bodyStr = '<unreadable>'; }\n");
222        script.push_str("  let reqHeaders = {};\n");
223        script.push_str(
224            "  if (res.request && res.request.headers) { reqHeaders = res.request.headers; }\n",
225        );
226        script.push_str("  let reqBody = '';\n");
227        script.push_str("  if (res.request && res.request.body) { try { const __m = res.request.body.length; reqBody = res.request.body.substring(0, 65536); if (__m > 65536) reqBody = reqBody + ' <truncated at 65536 bytes; full body was ' + __m + ' bytes>'; } catch(e) {} }\n");
228        script.push_str("  console.log('MOCKFORGE_FAILURE:' + JSON.stringify({\n");
229        script.push_str("    check: checkName,\n");
230        script.push_str("    request: {\n");
231        script.push_str("      method: res.request ? res.request.method : 'unknown',\n");
232        script.push_str("      url: res.request ? res.request.url : res.url || 'unknown',\n");
233        script.push_str("      headers: reqHeaders,\n");
234        script.push_str("      body: reqBody,\n");
235        script.push_str("    },\n");
236        script.push_str("    response: {\n");
237        script.push_str("      status: res.status,\n");
238        script.push_str("      headers: res.headers ? Object.fromEntries(Object.entries(res.headers).slice(0, 20)) : {},\n");
239        script.push_str("      body: bodyStr,\n");
240        script.push_str("    },\n");
241        script.push_str("    expected: expected,\n");
242        script.push_str("  }));\n");
243        script.push_str("}\n\n");
244
245        // Request/response capture for --export-requests (uses console.log since
246        // k6's handleSummary runs in a separate JS context with no access to
247        // module-level variables — the CLI parses the output log after k6 exits).
248        //
249        // Round 44 (#79) — Srikanth on 0.3.188: he reported `MOCKFORGE_UPLOAD_PARTS`
250        // appearing in k6-output.log but `MOCKFORGE_EXCHANGE` for the same check
251        // missing entirely from `conformance-requests.json` / `-failure-details.json`.
252        // The most likely failure mode is an exception inside `JSON.stringify`
253        // (multipart bodies can include bytes that produce surrogate-half strings
254        // k6's stringifier chokes on; very large request URLs can also bust k6's
255        // console-line length). Wrap the entire payload build + stringify in a
256        // try/catch and ALWAYS emit a fallback `MOCKFORGE_EXCHANGE` line — even
257        // when stringify fails — so the request never silently disappears from
258        // the export. The fallback carries `check`, method, URL, status, and an
259        // `_export_error` flag so a downstream consumer can tell a degraded
260        // entry from a clean one.
261        if self.config.export_requests {
262            script.push_str("function __captureExchange(checkName, res) {\n");
263            script.push_str("  try {\n");
264            script.push_str("    let bodyStr = '';\n");
265            script.push_str("    try { if (res.body) { const __n = res.body.length; bodyStr = res.body.substring(0, 65536); if (__n > 65536) bodyStr = bodyStr + ' <truncated at 65536 bytes; full body was ' + __n + ' bytes>'; } else { bodyStr = ''; } } catch(e) { bodyStr = '<unreadable>'; }\n");
266            script.push_str("    let reqHeaders = {};\n");
267            script.push_str(
268                "    if (res.request && res.request.headers) { reqHeaders = res.request.headers; }\n",
269            );
270            // Round 41 (#79) — Srikanth on 0.3.185: "When run without
271            // Spec the export request file has blank entry". k6's
272            // `res.request.body` is empty for multipart uploads (k6
273            // serialises the form internally and the JS-side body
274            // string is null). Fall back to a content-type-derived
275            // summary so the export at least surfaces "multipart/form-data; N parts"
276            // instead of an empty string. Real bodies still surface
277            // unchanged.
278            script.push_str("    let reqBody = '';\n");
279            script.push_str("    if (res.request && res.request.body) { try { const __m = res.request.body.length; reqBody = res.request.body.substring(0, 65536); if (__m > 65536) reqBody = reqBody + ' <truncated at 65536 bytes; full body was ' + __m + ' bytes>'; } catch(e) {} }\n");
280            script.push_str(
281                "    if (!reqBody) {\n\
282                 \x20\x20\x20\x20\x20\x20const ct = (reqHeaders['Content-Type'] || reqHeaders['content-type'] || '').toString();\n\
283                 \x20\x20\x20\x20\x20\x20if (ct.startsWith('multipart/')) reqBody = '<multipart upload; body bytes not surfaced by k6 res.request.body>';\n\
284                 \x20\x20\x20\x20}\n",
285            );
286            script.push_str("    console.log('MOCKFORGE_EXCHANGE:' + JSON.stringify({\n");
287            script.push_str("      check: checkName,\n");
288            script.push_str("      request: {\n");
289            script.push_str("        method: res.request ? res.request.method : 'unknown',\n");
290            script.push_str("        url: res.request ? res.request.url : res.url || 'unknown',\n");
291            script.push_str("        headers: reqHeaders,\n");
292            script.push_str("        body: reqBody,\n");
293            script.push_str("      },\n");
294            script.push_str("      response: {\n");
295            script.push_str("        status: res.status,\n");
296            script.push_str("        headers: res.headers ? Object.fromEntries(Object.entries(res.headers).slice(0, 30)) : {},\n");
297            script.push_str("        body: bodyStr,\n");
298            script.push_str("      },\n");
299            script.push_str("    }));\n");
300            script.push_str("  } catch (e) {\n");
301            // Fallback path: still emit SOMETHING the parser can pick up
302            // so the request doesn't vanish from the export. Stays short
303            // on purpose — bigger payload was what tripped the primary
304            // path.
305            script.push_str("    try {\n");
306            script.push_str("      console.log('MOCKFORGE_EXCHANGE:' + JSON.stringify({\n");
307            script.push_str("        check: checkName,\n");
308            script.push_str("        request: {\n");
309            script.push_str(
310                "          method: (res && res.request) ? res.request.method : 'unknown',\n",
311            );
312            script.push_str("          url: (res && res.request) ? res.request.url : (res && res.url) || 'unknown',\n");
313            script.push_str("          headers: {},\n");
314            script.push_str("          body: '<exchange capture failed: ' + (e && e.message ? e.message : 'unknown error') + '>',\n");
315            script.push_str("        },\n");
316            script.push_str("        response: {\n");
317            script.push_str("          status: (res && res.status) || 0,\n");
318            script.push_str("          headers: {},\n");
319            script.push_str("          body: '',\n");
320            script.push_str("        },\n");
321            script.push_str("        _export_error: (e && e.message) ? e.message : String(e),\n");
322            script.push_str("      }));\n");
323            script.push_str("    } catch (e2) {\n");
324            // Last-resort: a hand-rolled JSON string so even if a
325            // second stringify fails, we still flag the failure.
326            script.push_str("      console.log('MOCKFORGE_EXCHANGE:{\"check\":\"' + checkName + '\",\"request\":{\"method\":\"unknown\",\"url\":\"unknown\",\"headers\":{},\"body\":\"\"},\"response\":{\"status\":0,\"headers\":{},\"body\":\"\"},\"_export_error\":\"double-fault\"}');\n");
327            script.push_str("    }\n");
328            script.push_str("  }\n");
329            script.push_str("}\n\n");
330        }
331
332        // Default function
333        script.push_str("export default function () {\n");
334
335        if self.config.has_cookie_header() {
336            script.push_str(
337                "  // Clear cookie jar to prevent server Set-Cookie from duplicating custom Cookie header\n",
338            );
339            script.push_str("  http.cookieJar().clear(BASE_URL);\n\n");
340        }
341
342        // Helper to insert a delay between groups when --conformance-delay is set
343        let delay_between = if self.config.request_delay_ms > 0 {
344            "  sleep(REQUEST_DELAY);\n".to_string()
345        } else {
346            String::new()
347        };
348
349        // Round 39 (#79) — Srikanth on 0.3.183: "In the exported
350        // request I see it is sending request to
351        // api/conformance/params/hello and some other URLs". When the
352        // user passed `--conformance-custom` without `--spec`, the
353        // generator still emitted the 47 built-in reference checks
354        // against `/conformance/...` paths, which 404 on a real
355        // target. Skip them when custom checks are the only input —
356        // matching the native executor's `custom_only` branch.
357        let custom_only = self.config.custom_checks_file.is_some()
358            && !self.config.target_url.is_empty()
359            // Reference checks ARE the right answer when the user
360            // explicitly listed categories with --conformance-category.
361            && self.config.categories.is_none();
362        if !custom_only {
363            if self.config.should_include_category("Parameters") {
364                self.generate_parameters_group(&mut script);
365                script.push_str(&delay_between);
366            }
367            if self.config.should_include_category("Request Bodies") {
368                self.generate_request_bodies_group(&mut script);
369                script.push_str(&delay_between);
370            }
371            if self.config.should_include_category("Schema Types") {
372                self.generate_schema_types_group(&mut script);
373                script.push_str(&delay_between);
374            }
375            if self.config.should_include_category("Composition") {
376                self.generate_composition_group(&mut script);
377                script.push_str(&delay_between);
378            }
379            if self.config.should_include_category("String Formats") {
380                self.generate_string_formats_group(&mut script);
381                script.push_str(&delay_between);
382            }
383            if self.config.should_include_category("Constraints") {
384                self.generate_constraints_group(&mut script);
385                script.push_str(&delay_between);
386            }
387            if self.config.should_include_category("Response Codes") {
388                self.generate_response_codes_group(&mut script);
389                script.push_str(&delay_between);
390            }
391            if self.config.should_include_category("HTTP Methods") {
392                self.generate_http_methods_group(&mut script);
393                script.push_str(&delay_between);
394            }
395            if self.config.should_include_category("Content Types") {
396                self.generate_content_negotiation_group(&mut script);
397                script.push_str(&delay_between);
398            }
399            if self.config.should_include_category("Security") {
400                self.generate_security_group(&mut script);
401            }
402        }
403
404        // Custom checks from YAML file — round 39: we already called
405        // `generate_custom_group()` above to emit init-scope code, so
406        // here we just splice the group body inside the default
407        // function.
408        if let Some(emit) = custom_emit {
409            script.push_str(&emit.group_body);
410        }
411
412        script.push_str("}\n\n");
413
414        // handleSummary for conformance report output
415        self.generate_handle_summary(&mut script);
416
417        Ok(script)
418    }
419
420    /// Write the generated script to a file
421    pub fn write_script(&self, path: &Path) -> Result<()> {
422        let script = self.generate()?;
423        if let Some(parent) = path.parent() {
424            std::fs::create_dir_all(parent)?;
425        }
426        std::fs::write(path, script)
427            .map_err(|e| BenchError::Other(format!("Failed to write conformance script: {}", e)))
428    }
429
430    /// Returns a JS expression for merging custom headers with provided headers.
431    /// If no custom headers, returns the input as-is.
432    /// If custom headers exist, wraps with Object.assign using inline header object.
433    fn merge_with_custom_headers(&self, headers_expr: &str) -> String {
434        if self.config.has_custom_headers() {
435            format!(
436                "Object.assign({{}}, {}, {})",
437                headers_expr,
438                self.config.custom_headers_js_object()
439            )
440        } else {
441            headers_expr.to_string()
442        }
443    }
444
445    /// Emit a GET request with optional custom headers merged in.
446    fn emit_get(&self, script: &mut String, url: &str, extra_headers: Option<&str>) {
447        let has_custom = self.config.has_custom_headers();
448        let custom_obj = self.config.custom_headers_js_object();
449        match (extra_headers, has_custom) {
450            (None, false) => {
451                script.push_str(&format!("      let res = http.get(`{}`);\n", url));
452            }
453            (None, true) => {
454                script.push_str(&format!(
455                    "      let res = http.get(`{}`, {{ headers: {} }});\n",
456                    url, custom_obj
457                ));
458            }
459            (Some(hdrs), false) => {
460                script.push_str(&format!(
461                    "      let res = http.get(`{}`, {{ headers: {} }});\n",
462                    url, hdrs
463                ));
464            }
465            (Some(hdrs), true) => {
466                script.push_str(&format!(
467                    "      let res = http.get(`{}`, {{ headers: Object.assign({{}}, {}, {}) }});\n",
468                    url, hdrs, custom_obj
469                ));
470            }
471        }
472        self.maybe_clear_cookie_jar(script);
473        self.maybe_capture_exchange(script);
474    }
475
476    /// Emit a POST/PUT/PATCH request with optional custom headers merged in.
477    fn emit_post_like(
478        &self,
479        script: &mut String,
480        method: &str,
481        url: &str,
482        body: &str,
483        headers_expr: &str,
484    ) {
485        let merged = self.merge_with_custom_headers(headers_expr);
486        script.push_str(&format!(
487            "      let res = http.{}(`{}`, {}, {{ headers: {} }});\n",
488            method, url, body, merged
489        ));
490        self.maybe_clear_cookie_jar(script);
491        self.maybe_capture_exchange(script);
492    }
493
494    /// Emit a DELETE/HEAD/OPTIONS request with optional custom headers.
495    fn emit_no_body(&self, script: &mut String, method: &str, url: &str) {
496        if self.config.has_custom_headers() {
497            script.push_str(&format!(
498                "      let res = http.{}(`{}`, {{ headers: {} }});\n",
499                method,
500                url,
501                self.config.custom_headers_js_object()
502            ));
503        } else {
504            script.push_str(&format!("      let res = http.{}(`{}`);\n", method, url));
505        }
506        self.maybe_clear_cookie_jar(script);
507        self.maybe_capture_exchange(script);
508    }
509
510    /// Emit `__captureExchange` call when `--export-requests` is enabled.
511    fn maybe_capture_exchange(&self, script: &mut String) {
512        if self.config.export_requests {
513            script.push_str(
514                "      if (typeof __captureExchange === 'function') __captureExchange('', res);\n",
515            );
516        }
517    }
518
519    /// Emit cookie jar clearing after a request when custom Cookie headers are used.
520    /// Prevents k6's internal cookie jar from re-sending server Set-Cookie values
521    /// alongside the custom Cookie header on subsequent requests.
522    fn maybe_clear_cookie_jar(&self, script: &mut String) {
523        if self.config.has_cookie_header() {
524            script.push_str("      http.cookieJar().clear(BASE_URL);\n");
525        }
526    }
527
528    fn generate_parameters_group(&self, script: &mut String) {
529        script.push_str("  group('Parameters', function () {\n");
530
531        // Path param: string
532        script.push_str("    {\n");
533        self.emit_get(script, "${BASE_URL}/conformance/params/hello", None);
534        script.push_str(
535            "      check(res, { 'param:path:string': (r) => r.status >= 200 && r.status < 500 });\n",
536        );
537        script.push_str("    }\n");
538
539        // Path param: integer
540        script.push_str("    {\n");
541        self.emit_get(script, "${BASE_URL}/conformance/params/42", None);
542        script.push_str(
543            "      check(res, { 'param:path:integer': (r) => r.status >= 200 && r.status < 500 });\n",
544        );
545        script.push_str("    }\n");
546
547        // Query param: string
548        script.push_str("    {\n");
549        self.emit_get(script, "${BASE_URL}/conformance/params/query?name=test", None);
550        script.push_str(
551            "      check(res, { 'param:query:string': (r) => r.status >= 200 && r.status < 500 });\n",
552        );
553        script.push_str("    }\n");
554
555        // Query param: integer
556        script.push_str("    {\n");
557        self.emit_get(script, "${BASE_URL}/conformance/params/query?count=10", None);
558        script.push_str(
559            "      check(res, { 'param:query:integer': (r) => r.status >= 200 && r.status < 500 });\n",
560        );
561        script.push_str("    }\n");
562
563        // Query param: array
564        script.push_str("    {\n");
565        self.emit_get(script, "${BASE_URL}/conformance/params/query?tags=a&tags=b", None);
566        script.push_str(
567            "      check(res, { 'param:query:array': (r) => r.status >= 200 && r.status < 500 });\n",
568        );
569        script.push_str("    }\n");
570
571        // Header param
572        script.push_str("    {\n");
573        self.emit_get(
574            script,
575            "${BASE_URL}/conformance/params/header",
576            Some("{ 'X-Custom-Param': 'test-value' }"),
577        );
578        script.push_str(
579            "      check(res, { 'param:header': (r) => r.status >= 200 && r.status < 500 });\n",
580        );
581        script.push_str("    }\n");
582
583        // Cookie param
584        script.push_str("    {\n");
585        script.push_str("      let jar = http.cookieJar();\n");
586        script.push_str("      jar.set(BASE_URL, 'session', 'abc123');\n");
587        self.emit_get(script, "${BASE_URL}/conformance/params/cookie", None);
588        script.push_str(
589            "      check(res, { 'param:cookie': (r) => r.status >= 200 && r.status < 500 });\n",
590        );
591        script.push_str("    }\n");
592
593        script.push_str("  });\n\n");
594    }
595
596    fn generate_request_bodies_group(&self, script: &mut String) {
597        script.push_str("  group('Request Bodies', function () {\n");
598
599        // JSON body
600        script.push_str("    {\n");
601        self.emit_post_like(
602            script,
603            "post",
604            "${BASE_URL}/conformance/body/json",
605            "JSON.stringify({ name: 'test', value: 42 })",
606            "JSON_HEADERS",
607        );
608        script.push_str(
609            "      check(res, { 'body:json': (r) => r.status >= 200 && r.status < 500 });\n",
610        );
611        script.push_str("    }\n");
612
613        // Form-urlencoded body
614        script.push_str("    {\n");
615        if self.config.has_custom_headers() {
616            script.push_str(&format!(
617                "      let res = http.post(`${{BASE_URL}}/conformance/body/form`, {{ field1: 'value1', field2: 'value2' }}, {{ headers: {} }});\n",
618                self.config.custom_headers_js_object()
619            ));
620        } else {
621            script.push_str(
622                "      let res = http.post(`${BASE_URL}/conformance/body/form`, { field1: 'value1', field2: 'value2' });\n",
623            );
624        }
625        self.maybe_clear_cookie_jar(script);
626        script.push_str(
627            "      check(res, { 'body:form-urlencoded': (r) => r.status >= 200 && r.status < 500 });\n",
628        );
629        script.push_str("    }\n");
630
631        // Multipart body
632        script.push_str("    {\n");
633        script.push_str(
634            "      let data = { field: http.file('test content', 'test.txt', 'text/plain') };\n",
635        );
636        if self.config.has_custom_headers() {
637            script.push_str(&format!(
638                "      let res = http.post(`${{BASE_URL}}/conformance/body/multipart`, data, {{ headers: {} }});\n",
639                self.config.custom_headers_js_object()
640            ));
641        } else {
642            script.push_str(
643                "      let res = http.post(`${BASE_URL}/conformance/body/multipart`, data);\n",
644            );
645        }
646        self.maybe_clear_cookie_jar(script);
647        script.push_str(
648            "      check(res, { 'body:multipart': (r) => r.status >= 200 && r.status < 500 });\n",
649        );
650        script.push_str("    }\n");
651
652        script.push_str("  });\n\n");
653    }
654
655    fn generate_schema_types_group(&self, script: &mut String) {
656        script.push_str("  group('Schema Types', function () {\n");
657
658        let types = [
659            ("string", r#"{ "value": "hello" }"#, "schema:string"),
660            ("integer", r#"{ "value": 42 }"#, "schema:integer"),
661            ("number", r#"{ "value": 3.14 }"#, "schema:number"),
662            ("boolean", r#"{ "value": true }"#, "schema:boolean"),
663            ("array", r#"{ "value": [1, 2, 3] }"#, "schema:array"),
664            ("object", r#"{ "value": { "nested": "data" } }"#, "schema:object"),
665        ];
666
667        for (type_name, body, check_name) in types {
668            script.push_str("    {\n");
669            let url = format!("${{BASE_URL}}/conformance/schema/{}", type_name);
670            let body_str = format!("'{}'", body);
671            self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
672            script.push_str(&format!(
673                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
674                check_name
675            ));
676            script.push_str("    }\n");
677        }
678
679        script.push_str("  });\n\n");
680    }
681
682    fn generate_composition_group(&self, script: &mut String) {
683        script.push_str("  group('Composition', function () {\n");
684
685        let compositions = [
686            ("oneOf", r#"{ "type": "string", "value": "test" }"#, "composition:oneOf"),
687            ("anyOf", r#"{ "value": "test" }"#, "composition:anyOf"),
688            ("allOf", r#"{ "name": "test", "id": 1 }"#, "composition:allOf"),
689        ];
690
691        for (kind, body, check_name) in compositions {
692            script.push_str("    {\n");
693            let url = format!("${{BASE_URL}}/conformance/composition/{}", kind);
694            let body_str = format!("'{}'", body);
695            self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
696            script.push_str(&format!(
697                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
698                check_name
699            ));
700            script.push_str("    }\n");
701        }
702
703        script.push_str("  });\n\n");
704    }
705
706    fn generate_string_formats_group(&self, script: &mut String) {
707        script.push_str("  group('String Formats', function () {\n");
708
709        let formats = [
710            ("date", r#"{ "value": "2024-01-15" }"#, "format:date"),
711            ("date-time", r#"{ "value": "2024-01-15T10:30:00Z" }"#, "format:date-time"),
712            ("email", r#"{ "value": "test@example.com" }"#, "format:email"),
713            ("uuid", r#"{ "value": "550e8400-e29b-41d4-a716-446655440000" }"#, "format:uuid"),
714            ("uri", r#"{ "value": "https://example.com/path" }"#, "format:uri"),
715            ("ipv4", r#"{ "value": "192.168.1.1" }"#, "format:ipv4"),
716            ("ipv6", r#"{ "value": "::1" }"#, "format:ipv6"),
717        ];
718
719        for (fmt, body, check_name) in formats {
720            script.push_str("    {\n");
721            let url = format!("${{BASE_URL}}/conformance/formats/{}", fmt);
722            let body_str = format!("'{}'", body);
723            self.emit_post_like(script, "post", &url, &body_str, "JSON_HEADERS");
724            script.push_str(&format!(
725                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
726                check_name
727            ));
728            script.push_str("    }\n");
729        }
730
731        script.push_str("  });\n\n");
732    }
733
734    fn generate_constraints_group(&self, script: &mut String) {
735        script.push_str("  group('Constraints', function () {\n");
736
737        let constraints = [
738            (
739                "required",
740                "JSON.stringify({ required_field: 'present' })",
741                "constraint:required",
742            ),
743            ("optional", "JSON.stringify({})", "constraint:optional"),
744            ("minmax", "JSON.stringify({ value: 50 })", "constraint:minmax"),
745            ("pattern", "JSON.stringify({ value: 'ABC-123' })", "constraint:pattern"),
746            ("enum", "JSON.stringify({ status: 'active' })", "constraint:enum"),
747        ];
748
749        for (kind, body, check_name) in constraints {
750            script.push_str("    {\n");
751            let url = format!("${{BASE_URL}}/conformance/constraints/{}", kind);
752            self.emit_post_like(script, "post", &url, body, "JSON_HEADERS");
753            script.push_str(&format!(
754                "      check(res, {{ '{}': (r) => r.status >= 200 && r.status < 500 }});\n",
755                check_name
756            ));
757            script.push_str("    }\n");
758        }
759
760        script.push_str("  });\n\n");
761    }
762
763    fn generate_response_codes_group(&self, script: &mut String) {
764        script.push_str("  group('Response Codes', function () {\n");
765
766        let codes = [
767            ("200", "response:200"),
768            ("201", "response:201"),
769            ("204", "response:204"),
770            ("400", "response:400"),
771            ("404", "response:404"),
772        ];
773
774        for (code, check_name) in codes {
775            script.push_str("    {\n");
776            let url = format!("${{BASE_URL}}/conformance/responses/{}", code);
777            self.emit_get(script, &url, None);
778            script.push_str(&format!(
779                "      check(res, {{ '{}': (r) => r.status === {} }});\n",
780                check_name, code
781            ));
782            script.push_str("    }\n");
783        }
784
785        script.push_str("  });\n\n");
786    }
787
788    fn generate_http_methods_group(&self, script: &mut String) {
789        script.push_str("  group('HTTP Methods', function () {\n");
790
791        // GET
792        script.push_str("    {\n");
793        self.emit_get(script, "${BASE_URL}/conformance/methods", None);
794        script.push_str(
795            "      check(res, { 'method:GET': (r) => r.status >= 200 && r.status < 500 });\n",
796        );
797        script.push_str("    }\n");
798
799        // POST
800        script.push_str("    {\n");
801        self.emit_post_like(
802            script,
803            "post",
804            "${BASE_URL}/conformance/methods",
805            "JSON.stringify({ action: 'create' })",
806            "JSON_HEADERS",
807        );
808        script.push_str(
809            "      check(res, { 'method:POST': (r) => r.status >= 200 && r.status < 500 });\n",
810        );
811        script.push_str("    }\n");
812
813        // PUT
814        script.push_str("    {\n");
815        self.emit_post_like(
816            script,
817            "put",
818            "${BASE_URL}/conformance/methods",
819            "JSON.stringify({ action: 'update' })",
820            "JSON_HEADERS",
821        );
822        script.push_str(
823            "      check(res, { 'method:PUT': (r) => r.status >= 200 && r.status < 500 });\n",
824        );
825        script.push_str("    }\n");
826
827        // PATCH
828        script.push_str("    {\n");
829        self.emit_post_like(
830            script,
831            "patch",
832            "${BASE_URL}/conformance/methods",
833            "JSON.stringify({ action: 'patch' })",
834            "JSON_HEADERS",
835        );
836        script.push_str(
837            "      check(res, { 'method:PATCH': (r) => r.status >= 200 && r.status < 500 });\n",
838        );
839        script.push_str("    }\n");
840
841        // DELETE
842        script.push_str("    {\n");
843        self.emit_no_body(script, "del", "${BASE_URL}/conformance/methods");
844        script.push_str(
845            "      check(res, { 'method:DELETE': (r) => r.status >= 200 && r.status < 500 });\n",
846        );
847        script.push_str("    }\n");
848
849        // HEAD
850        script.push_str("    {\n");
851        self.emit_no_body(script, "head", "${BASE_URL}/conformance/methods");
852        script.push_str(
853            "      check(res, { 'method:HEAD': (r) => r.status >= 200 && r.status < 500 });\n",
854        );
855        script.push_str("    }\n");
856
857        // OPTIONS
858        script.push_str("    {\n");
859        self.emit_no_body(script, "options", "${BASE_URL}/conformance/methods");
860        script.push_str(
861            "      check(res, { 'method:OPTIONS': (r) => r.status >= 200 && r.status < 500 });\n",
862        );
863        script.push_str("    }\n");
864
865        script.push_str("  });\n\n");
866    }
867
868    fn generate_content_negotiation_group(&self, script: &mut String) {
869        script.push_str("  group('Content Types', function () {\n");
870
871        script.push_str("    {\n");
872        self.emit_get(
873            script,
874            "${BASE_URL}/conformance/content-types",
875            Some("{ 'Accept': 'application/json' }"),
876        );
877        script.push_str(
878            "      check(res, { 'content:negotiation': (r) => r.status >= 200 && r.status < 500 });\n",
879        );
880        script.push_str("    }\n");
881
882        script.push_str("  });\n\n");
883    }
884
885    fn generate_security_group(&self, script: &mut String) {
886        script.push_str("  group('Security', function () {\n");
887
888        // Bearer token
889        script.push_str("    {\n");
890        self.emit_get(
891            script,
892            "${BASE_URL}/conformance/security/bearer",
893            Some("{ 'Authorization': 'Bearer test-token-123' }"),
894        );
895        script.push_str(
896            "      check(res, { 'security:bearer': (r) => r.status >= 200 && r.status < 500 });\n",
897        );
898        script.push_str("    }\n");
899
900        // API Key
901        let api_key = self.config.api_key.as_deref().unwrap_or("test-api-key-123");
902        script.push_str("    {\n");
903        let api_key_hdrs = format!("{{ 'X-API-Key': '{}' }}", api_key);
904        self.emit_get(script, "${BASE_URL}/conformance/security/apikey", Some(&api_key_hdrs));
905        script.push_str(
906            "      check(res, { 'security:apikey': (r) => r.status >= 200 && r.status < 500 });\n",
907        );
908        script.push_str("    }\n");
909
910        // Basic auth
911        let basic_creds = self.config.basic_auth.as_deref().unwrap_or("user:pass");
912        let encoded = base64_encode(basic_creds);
913        script.push_str("    {\n");
914        let basic_hdrs = format!("{{ 'Authorization': 'Basic {}' }}", encoded);
915        self.emit_get(script, "${BASE_URL}/conformance/security/basic", Some(&basic_hdrs));
916        script.push_str(
917            "      check(res, { 'security:basic': (r) => r.status >= 200 && r.status < 500 });\n",
918        );
919        script.push_str("    }\n");
920
921        script.push_str("  });\n\n");
922    }
923
924    fn generate_handle_summary(&self, script: &mut String) {
925        // Determine the report output path. When output_dir is set, use an absolute
926        // path so k6 writes the file where the CLI expects to find it regardless of CWD.
927        let report_path = match &self.config.output_dir {
928            Some(dir) => {
929                let abs = std::fs::canonicalize(dir)
930                    .unwrap_or_else(|_| dir.clone())
931                    .join("conformance-report.json");
932                abs.to_string_lossy().to_string()
933            }
934            None => "conformance-report.json".to_string(),
935        };
936
937        script.push_str("export function handleSummary(data) {\n");
938        script.push_str("  // Extract check results for conformance reporting\n");
939        script.push_str("  let checks = {};\n");
940        script.push_str("  if (data.metrics && data.metrics.checks) {\n");
941        script.push_str("    // Overall check pass rate\n");
942        script.push_str("    checks.overall_pass_rate = data.metrics.checks.values.rate;\n");
943        script.push_str("  }\n");
944        script.push_str("  // Collect per-check results from root_group\n");
945        script.push_str("  let checkResults = {};\n");
946        script.push_str("  function walkGroups(group) {\n");
947        script.push_str("    if (group.checks) {\n");
948        script.push_str("      for (let checkObj of group.checks) {\n");
949        script.push_str("        checkResults[checkObj.name] = {\n");
950        script.push_str("          passes: checkObj.passes,\n");
951        script.push_str("          fails: checkObj.fails,\n");
952        script.push_str("        };\n");
953        script.push_str("      }\n");
954        script.push_str("    }\n");
955        script.push_str("    if (group.groups) {\n");
956        script.push_str("      for (let subGroup of group.groups) {\n");
957        script.push_str("        walkGroups(subGroup);\n");
958        script.push_str("      }\n");
959        script.push_str("    }\n");
960        script.push_str("  }\n");
961        script.push_str("  if (data.root_group) {\n");
962        script.push_str("    walkGroups(data.root_group);\n");
963        script.push_str("  }\n");
964        script.push_str("  let result = {\n");
965        script.push_str(&format!(
966            "    '{}': JSON.stringify({{ checks: checkResults, overall: checks }}, null, 2),\n",
967            report_path
968        ));
969        script.push_str("    'summary.json': JSON.stringify(data),\n");
970        script.push_str("    stdout: textSummary(data, { indent: '  ', enableColors: true }),\n");
971        script.push_str("  };\n");
972        script.push_str("  return result;\n");
973        script.push_str("}\n\n");
974        script.push_str("// textSummary fallback\n");
975        script.push_str("function textSummary(data, opts) {\n");
976        script.push_str("  return JSON.stringify(data, null, 2);\n");
977        script.push_str("}\n");
978    }
979}
980
981/// Simple base64 encoding for basic auth
982fn base64_encode(input: &str) -> String {
983    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
984    let bytes = input.as_bytes();
985    let mut result = String::with_capacity(bytes.len().div_ceil(3) * 4);
986    for chunk in bytes.chunks(3) {
987        let b0 = chunk[0] as u32;
988        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
989        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
990        let triple = (b0 << 16) | (b1 << 8) | b2;
991        result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
992        result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
993        if chunk.len() > 1 {
994            result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
995        } else {
996            result.push('=');
997        }
998        if chunk.len() > 2 {
999            result.push(CHARS[(triple & 0x3F) as usize] as char);
1000        } else {
1001            result.push('=');
1002        }
1003    }
1004    result
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009    use super::*;
1010
1011    #[test]
1012    fn test_generate_conformance_script() {
1013        let config = ConformanceConfig {
1014            target_url: "http://localhost:8080".to_string(),
1015            api_key: None,
1016            basic_auth: None,
1017            skip_tls_verify: false,
1018            categories: None,
1019            base_path: None,
1020            custom_headers: vec![],
1021            output_dir: None,
1022            all_operations: false,
1023            custom_checks_file: None,
1024            request_delay_ms: 0,
1025            custom_filter: None,
1026            export_requests: false,
1027            validate_requests: false,
1028        };
1029        let generator = ConformanceGenerator::new(config);
1030        let script = generator.generate().unwrap();
1031
1032        assert!(script.contains("import http from 'k6/http'"));
1033        assert!(script.contains("vus: 1"));
1034        assert!(script.contains("iterations: 1"));
1035        assert!(script.contains("group('Parameters'"));
1036        assert!(script.contains("group('Request Bodies'"));
1037        assert!(script.contains("group('Schema Types'"));
1038        assert!(script.contains("group('Composition'"));
1039        assert!(script.contains("group('String Formats'"));
1040        assert!(script.contains("group('Constraints'"));
1041        assert!(script.contains("group('Response Codes'"));
1042        assert!(script.contains("group('HTTP Methods'"));
1043        assert!(script.contains("group('Content Types'"));
1044        assert!(script.contains("group('Security'"));
1045        assert!(script.contains("handleSummary"));
1046    }
1047
1048    #[test]
1049    fn test_base64_encode() {
1050        assert_eq!(base64_encode("user:pass"), "dXNlcjpwYXNz");
1051        assert_eq!(base64_encode("a"), "YQ==");
1052        assert_eq!(base64_encode("ab"), "YWI=");
1053        assert_eq!(base64_encode("abc"), "YWJj");
1054    }
1055
1056    #[test]
1057    fn test_conformance_script_with_custom_auth() {
1058        let config = ConformanceConfig {
1059            target_url: "https://api.example.com".to_string(),
1060            api_key: Some("my-api-key".to_string()),
1061            basic_auth: Some("admin:secret".to_string()),
1062            skip_tls_verify: true,
1063            categories: None,
1064            base_path: None,
1065            custom_headers: vec![],
1066            output_dir: None,
1067            all_operations: false,
1068            custom_checks_file: None,
1069            request_delay_ms: 0,
1070            custom_filter: None,
1071            export_requests: false,
1072            validate_requests: false,
1073        };
1074        let generator = ConformanceGenerator::new(config);
1075        let script = generator.generate().unwrap();
1076
1077        assert!(script.contains("insecureSkipTLSVerify: true"));
1078        assert!(script.contains("my-api-key"));
1079        assert!(script.contains(&base64_encode("admin:secret")));
1080    }
1081
1082    #[test]
1083    fn test_should_include_category_none_includes_all() {
1084        let config = ConformanceConfig {
1085            target_url: "http://localhost:8080".to_string(),
1086            api_key: None,
1087            basic_auth: None,
1088            skip_tls_verify: false,
1089            categories: None,
1090            base_path: None,
1091            custom_headers: vec![],
1092            output_dir: None,
1093            all_operations: false,
1094            custom_checks_file: None,
1095            request_delay_ms: 0,
1096            custom_filter: None,
1097            export_requests: false,
1098            validate_requests: false,
1099        };
1100        assert!(config.should_include_category("Parameters"));
1101        assert!(config.should_include_category("Security"));
1102        assert!(config.should_include_category("Anything"));
1103    }
1104
1105    #[test]
1106    fn test_should_include_category_filtered() {
1107        let config = ConformanceConfig {
1108            target_url: "http://localhost:8080".to_string(),
1109            api_key: None,
1110            basic_auth: None,
1111            skip_tls_verify: false,
1112            categories: Some(vec!["Parameters".to_string(), "Security".to_string()]),
1113            base_path: None,
1114            custom_headers: vec![],
1115            output_dir: None,
1116            all_operations: false,
1117            custom_checks_file: None,
1118            request_delay_ms: 0,
1119            custom_filter: None,
1120            export_requests: false,
1121            validate_requests: false,
1122        };
1123        assert!(config.should_include_category("Parameters"));
1124        assert!(config.should_include_category("Security"));
1125        assert!(config.should_include_category("parameters")); // case-insensitive
1126        assert!(!config.should_include_category("Composition"));
1127        assert!(!config.should_include_category("Schema Types"));
1128    }
1129
1130    #[test]
1131    fn test_generate_with_category_filter() {
1132        let config = ConformanceConfig {
1133            target_url: "http://localhost:8080".to_string(),
1134            api_key: None,
1135            basic_auth: None,
1136            skip_tls_verify: false,
1137            categories: Some(vec!["Parameters".to_string(), "Security".to_string()]),
1138            base_path: None,
1139            custom_headers: vec![],
1140            output_dir: None,
1141            all_operations: false,
1142            custom_checks_file: None,
1143            request_delay_ms: 0,
1144            custom_filter: None,
1145            export_requests: false,
1146            validate_requests: false,
1147        };
1148        let generator = ConformanceGenerator::new(config);
1149        let script = generator.generate().unwrap();
1150
1151        assert!(script.contains("group('Parameters'"));
1152        assert!(script.contains("group('Security'"));
1153        assert!(!script.contains("group('Request Bodies'"));
1154        assert!(!script.contains("group('Schema Types'"));
1155        assert!(!script.contains("group('Composition'"));
1156    }
1157
1158    #[test]
1159    fn test_effective_base_url_no_base_path() {
1160        let config = ConformanceConfig {
1161            target_url: "https://example.com".to_string(),
1162            api_key: None,
1163            basic_auth: None,
1164            skip_tls_verify: false,
1165            categories: None,
1166            base_path: None,
1167            custom_headers: vec![],
1168            output_dir: None,
1169            all_operations: false,
1170            custom_checks_file: None,
1171            request_delay_ms: 0,
1172            custom_filter: None,
1173            export_requests: false,
1174            validate_requests: false,
1175        };
1176        assert_eq!(config.effective_base_url(), "https://example.com");
1177    }
1178
1179    #[test]
1180    fn test_effective_base_url_with_base_path() {
1181        let config = ConformanceConfig {
1182            target_url: "https://example.com".to_string(),
1183            api_key: None,
1184            basic_auth: None,
1185            skip_tls_verify: false,
1186            categories: None,
1187            base_path: Some("/api".to_string()),
1188            custom_headers: vec![],
1189            output_dir: None,
1190            all_operations: false,
1191            custom_checks_file: None,
1192            request_delay_ms: 0,
1193            custom_filter: None,
1194            export_requests: false,
1195            validate_requests: false,
1196        };
1197        assert_eq!(config.effective_base_url(), "https://example.com/api");
1198    }
1199
1200    #[test]
1201    fn test_effective_base_url_trailing_slash_normalization() {
1202        let config = ConformanceConfig {
1203            target_url: "https://example.com/".to_string(),
1204            api_key: None,
1205            basic_auth: None,
1206            skip_tls_verify: false,
1207            categories: None,
1208            base_path: Some("/api".to_string()),
1209            custom_headers: vec![],
1210            output_dir: None,
1211            all_operations: false,
1212            custom_checks_file: None,
1213            request_delay_ms: 0,
1214            custom_filter: None,
1215            export_requests: false,
1216            validate_requests: false,
1217        };
1218        assert_eq!(config.effective_base_url(), "https://example.com/api");
1219    }
1220
1221    #[test]
1222    fn test_effective_base_url_trailing_slash_no_base_path() {
1223        // Regression: --target https://192.168.2.86/ without --base-path
1224        // must not produce double slashes when combined with /path
1225        let config = ConformanceConfig {
1226            target_url: "https://192.168.2.86/".to_string(),
1227            api_key: None,
1228            basic_auth: None,
1229            skip_tls_verify: false,
1230            categories: None,
1231            base_path: None,
1232            custom_headers: vec![],
1233            output_dir: None,
1234            all_operations: false,
1235            custom_checks_file: None,
1236            request_delay_ms: 0,
1237            custom_filter: None,
1238            export_requests: false,
1239            validate_requests: false,
1240        };
1241        assert_eq!(config.effective_base_url(), "https://192.168.2.86");
1242    }
1243
1244    #[test]
1245    fn test_generate_script_with_base_path() {
1246        let config = ConformanceConfig {
1247            target_url: "https://192.168.2.86".to_string(),
1248            api_key: None,
1249            basic_auth: None,
1250            skip_tls_verify: true,
1251            categories: None,
1252            base_path: Some("/api".to_string()),
1253            custom_headers: vec![],
1254            output_dir: None,
1255            all_operations: false,
1256            custom_checks_file: None,
1257            request_delay_ms: 0,
1258            custom_filter: None,
1259            export_requests: false,
1260            validate_requests: false,
1261        };
1262        let generator = ConformanceGenerator::new(config);
1263        let script = generator.generate().unwrap();
1264
1265        assert!(script.contains("const BASE_URL = 'https://192.168.2.86/api'"));
1266        // Verify URLs include the base path via BASE_URL
1267        assert!(script.contains("${BASE_URL}/conformance/"));
1268    }
1269
1270    #[test]
1271    fn test_generate_with_custom_headers() {
1272        let config = ConformanceConfig {
1273            target_url: "https://192.168.2.86".to_string(),
1274            api_key: None,
1275            basic_auth: None,
1276            skip_tls_verify: true,
1277            categories: Some(vec!["Parameters".to_string()]),
1278            base_path: Some("/api".to_string()),
1279            custom_headers: vec![
1280                ("X-Avi-Tenant".to_string(), "admin".to_string()),
1281                ("X-CSRFToken".to_string(), "real-token".to_string()),
1282            ],
1283            output_dir: None,
1284            all_operations: false,
1285            custom_checks_file: None,
1286            request_delay_ms: 0,
1287            custom_filter: None,
1288            export_requests: false,
1289            validate_requests: false,
1290        };
1291        let generator = ConformanceGenerator::new(config);
1292        let script = generator.generate().unwrap();
1293
1294        // Custom headers should be inlined into requests (no separate const)
1295        assert!(
1296            !script.contains("const CUSTOM_HEADERS"),
1297            "Script should NOT declare a CUSTOM_HEADERS const"
1298        );
1299        assert!(script.contains("'X-Avi-Tenant': 'admin'"));
1300        assert!(script.contains("'X-CSRFToken': 'real-token'"));
1301    }
1302
1303    #[test]
1304    fn test_custom_headers_js_object() {
1305        let config = ConformanceConfig {
1306            target_url: "http://localhost".to_string(),
1307            api_key: None,
1308            basic_auth: None,
1309            skip_tls_verify: false,
1310            categories: None,
1311            base_path: None,
1312            custom_headers: vec![
1313                ("Authorization".to_string(), "Bearer abc123".to_string()),
1314                ("X-Custom".to_string(), "value".to_string()),
1315            ],
1316            output_dir: None,
1317            all_operations: false,
1318            custom_checks_file: None,
1319            request_delay_ms: 0,
1320            custom_filter: None,
1321            export_requests: false,
1322            validate_requests: false,
1323        };
1324        let js = config.custom_headers_js_object();
1325        assert!(js.contains("'Authorization': 'Bearer abc123'"));
1326        assert!(js.contains("'X-Custom': 'value'"));
1327    }
1328}