Skip to main content

alef_e2e/
fixture.rs

1//! Fixture loading, validation, and grouping for e2e test generation.
2
3use anyhow::{Context, Result, bail};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::Path;
7
8/// Mock HTTP response for testing HTTP clients.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct MockResponse {
11    /// HTTP status code.
12    pub status: u16,
13    /// JSON response body (for non-streaming responses).
14    #[serde(default)]
15    pub body: Option<serde_json::Value>,
16    /// SSE stream chunks (for streaming responses).
17    /// Each chunk is a JSON object sent as `data: <chunk>\n\n`.
18    #[serde(default)]
19    pub stream_chunks: Option<Vec<serde_json::Value>>,
20    /// Response headers to apply to the mock response.
21    /// Bridged from `http.expected_response.headers` for consumer-style fixtures.
22    #[serde(default)]
23    pub headers: HashMap<String, String>,
24}
25
26/// Visitor specification for visitor pattern tests.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct VisitorSpec {
29    /// Map of callback method name to action.
30    pub callbacks: HashMap<String, CallbackAction>,
31}
32
33/// Action a visitor callback should take.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(tag = "action")]
36pub enum CallbackAction {
37    /// Return VisitResult::Skip.
38    #[serde(rename = "skip")]
39    Skip,
40    /// Return VisitResult::Continue.
41    #[serde(rename = "continue")]
42    Continue,
43    /// Return VisitResult::PreserveHtml.
44    #[serde(rename = "preserve_html")]
45    PreserveHtml,
46    /// Return VisitResult::Custom with static output.
47    #[serde(rename = "custom")]
48    Custom {
49        /// The static replacement string.
50        output: String,
51    },
52    /// Return VisitResult::Custom with template interpolation.
53    #[serde(rename = "custom_template")]
54    CustomTemplate {
55        /// Template with placeholders like {text}, {href}.
56        template: String,
57    },
58}
59
60/// Environment variable requirements for a smoke/live test fixture.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct FixtureEnv {
63    /// Name of the env var that holds the API key (e.g. `"OPENAI_API_KEY"`).
64    #[serde(default)]
65    pub api_key_var: Option<String>,
66}
67
68/// A single e2e test fixture.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct Fixture {
71    /// Unique identifier (used as test function name).
72    pub id: String,
73    /// Optional category (defaults to parent directory name).
74    #[serde(default)]
75    pub category: Option<String>,
76    /// Human-readable description.
77    pub description: String,
78    /// Optional tags for filtering.
79    #[serde(default)]
80    pub tags: Vec<String>,
81    /// Skip directive.
82    #[serde(default)]
83    pub skip: Option<SkipDirective>,
84    /// Environment variable requirements (used by smoke/live tests).
85    #[serde(default)]
86    pub env: Option<FixtureEnv>,
87    /// Named call config to use (references `[e2e.calls.<name>]`).
88    /// When omitted, uses the default `[e2e.call]`.
89    #[serde(default)]
90    pub call: Option<String>,
91    /// Input data passed to the function under test.
92    #[serde(default)]
93    pub input: serde_json::Value,
94    /// Optional mock HTTP response for testing HTTP clients.
95    #[serde(default)]
96    pub mock_response: Option<MockResponse>,
97    /// Optional visitor specification for visitor pattern tests.
98    #[serde(default)]
99    pub visitor: Option<VisitorSpec>,
100    /// List of assertions to check.
101    #[serde(default)]
102    pub assertions: Vec<Assertion>,
103    /// Source file path (populated during loading).
104    #[serde(skip)]
105    pub source: String,
106    /// HTTP server test specification. When present, this fixture tests
107    /// an HTTP handler rather than a function call.
108    #[serde(default)]
109    pub http: Option<HttpFixture>,
110}
111
112/// HTTP server test specification.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct HttpFixture {
115    /// Handler/route definition.
116    pub handler: HttpHandler,
117    /// The HTTP request to send.
118    pub request: HttpRequest,
119    /// Expected response.
120    pub expected_response: HttpExpectedResponse,
121}
122
123/// Handler/route definition for HTTP server tests.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct HttpHandler {
126    /// Route pattern (e.g., "/users/{user_id}").
127    pub route: String,
128    /// HTTP method (GET, POST, PUT, etc.).
129    pub method: String,
130    /// JSON Schema for request body validation.
131    #[serde(default)]
132    pub body_schema: Option<serde_json::Value>,
133    /// Parameter schemas by source (path, query, header, cookie).
134    #[serde(default)]
135    pub parameters: HashMap<String, HashMap<String, serde_json::Value>>,
136    /// Middleware configuration.
137    #[serde(default)]
138    pub middleware: Option<HttpMiddleware>,
139}
140
141/// HTTP request to send in a server test.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct HttpRequest {
144    pub method: String,
145    pub path: String,
146    #[serde(default)]
147    pub headers: HashMap<String, String>,
148    #[serde(default)]
149    pub query_params: HashMap<String, serde_json::Value>,
150    #[serde(default)]
151    pub cookies: HashMap<String, String>,
152    #[serde(default)]
153    pub body: Option<serde_json::Value>,
154    #[serde(default)]
155    pub content_type: Option<String>,
156}
157
158/// Expected HTTP response specification.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct HttpExpectedResponse {
161    pub status_code: u16,
162    /// Exact body match.
163    #[serde(default)]
164    pub body: Option<serde_json::Value>,
165    /// Partial body match (only check specified fields).
166    #[serde(default)]
167    pub body_partial: Option<serde_json::Value>,
168    /// Header expectations. Special tokens: `<<uuid>>`, `<<present>>`, `<<absent>>`.
169    #[serde(default)]
170    pub headers: HashMap<String, String>,
171    /// Expected validation errors (for 422 responses).
172    #[serde(default)]
173    pub validation_errors: Option<Vec<ValidationErrorExpectation>>,
174}
175
176/// Expected validation error entry.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ValidationErrorExpectation {
179    pub loc: Vec<String>,
180    pub msg: String,
181    #[serde(rename = "type")]
182    pub error_type: String,
183}
184
185/// CORS policy configuration for HTTP handler tests.
186#[derive(Debug, Clone, Default, Serialize, Deserialize)]
187pub struct CorsConfig {
188    /// Allowed origins (e.g. `["https://example.com"]`). Empty means deny all.
189    #[serde(default)]
190    pub allow_origins: Vec<String>,
191    /// Allowed HTTP methods (e.g. `["GET", "POST"]`). Empty means deny all.
192    #[serde(default)]
193    pub allow_methods: Vec<String>,
194    /// Allowed request headers (e.g. `["Content-Type"]`). Empty means deny all.
195    #[serde(default)]
196    pub allow_headers: Vec<String>,
197    /// Exposed response headers (e.g. `["X-Total-Count"]`).
198    #[serde(default)]
199    pub expose_headers: Vec<String>,
200    /// `Access-Control-Max-Age` value in seconds.
201    #[serde(default)]
202    pub max_age: Option<u64>,
203    /// Whether to allow credentials.
204    #[serde(default)]
205    pub allow_credentials: bool,
206}
207
208/// A single static file entry for the static-files middleware.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct StaticFile {
211    /// Relative path within the served directory (e.g. `"hello.txt"`).
212    pub path: String,
213    /// File content (plain text or HTML string).
214    pub content: String,
215}
216
217/// Static-files middleware configuration for HTTP handler tests.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct StaticFilesConfig {
220    /// URL route prefix (e.g. `"/public"`).
221    pub route_prefix: String,
222    /// Files to write to the temporary directory.
223    #[serde(default)]
224    pub files: Vec<StaticFile>,
225    /// Whether to serve `index.html` for directory requests.
226    #[serde(default)]
227    pub index_file: bool,
228    /// `Cache-Control` header value to apply.
229    #[serde(default)]
230    pub cache_control: Option<String>,
231}
232
233/// Middleware configuration for HTTP handler tests.
234#[derive(Debug, Clone, Default, Serialize, Deserialize)]
235pub struct HttpMiddleware {
236    #[serde(default)]
237    pub jwt_auth: Option<serde_json::Value>,
238    #[serde(default)]
239    pub api_key_auth: Option<serde_json::Value>,
240    #[serde(default)]
241    pub compression: Option<serde_json::Value>,
242    #[serde(default)]
243    pub rate_limit: Option<serde_json::Value>,
244    #[serde(default)]
245    pub request_timeout: Option<serde_json::Value>,
246    #[serde(default)]
247    pub request_id: Option<serde_json::Value>,
248    /// CORS policy to apply via tower-http `CorsLayer`.
249    #[serde(default)]
250    pub cors: Option<CorsConfig>,
251    /// Static-files configuration to serve via tower-http `ServeDir`.
252    #[serde(default)]
253    pub static_files: Option<Vec<StaticFilesConfig>>,
254}
255
256/// Returns true for paths that the crawler fetches from the host root rather than under a
257/// fixture-namespaced prefix.  Mirrors the identical predicate in the standalone mock-server
258/// binary (`codegen/rust/mock_server.rs`).
259fn is_host_root_path(path: &str) -> bool {
260    path.starts_with("/robots") || path.starts_with("/sitemap")
261}
262
263impl Fixture {
264    /// Returns true if this is an HTTP server test fixture.
265    pub fn is_http_test(&self) -> bool {
266        self.http.is_some()
267    }
268
269    /// Returns true if this fixture requires a mock HTTP server.
270    /// This is true when either `mock_response` (liter-llm shape),
271    /// `http.expected_response` (consumer shape), or the kreuzcrawl-style
272    /// `input.mock_responses` array is non-empty.
273    pub fn needs_mock_server(&self) -> bool {
274        if self.mock_response.is_some() || self.http.is_some() {
275            return true;
276        }
277        // kreuzcrawl-style: input.mock_responses array with at least one entry
278        self.input
279            .get("mock_responses")
280            .and_then(|v| v.as_array())
281            .map(|arr| !arr.is_empty())
282            .unwrap_or(false)
283    }
284
285    /// Returns the effective mock response for this fixture, bridging both schemas:
286    /// - liter-llm shape: `mock_response: { status, body, stream_chunks }`
287    /// - consumer shape: `http.expected_response: { status_code, body, headers }`
288    ///
289    /// Returns `None` if neither schema is present.
290    pub fn as_mock_response(&self) -> Option<MockResponse> {
291        if let Some(mock) = &self.mock_response {
292            return Some(mock.clone());
293        }
294        if let Some(http) = &self.http {
295            return Some(MockResponse {
296                status: http.expected_response.status_code,
297                body: http.expected_response.body.clone(),
298                stream_chunks: None,
299                headers: http.expected_response.headers.clone(),
300            });
301        }
302        None
303    }
304
305    /// Returns true if the mock response uses streaming (SSE).
306    pub fn is_streaming_mock(&self) -> bool {
307        self.mock_response
308            .as_ref()
309            .and_then(|m| m.stream_chunks.as_ref())
310            .map(|c| !c.is_empty())
311            .unwrap_or(false)
312    }
313
314    /// Returns true if any of this fixture's mock response paths are host-root paths —
315    /// i.e. paths the crawler fetches from the host root rather than under a
316    /// fixture-namespaced prefix.  Mirrors the `is_host_root_path` predicate in the
317    /// standalone mock-server binary (`codegen/rust/mock_server.rs`).
318    ///
319    /// Host-root fixtures get a dedicated per-fixture listener and their base URL is
320    /// published in the `MOCK_SERVERS={"fixture_id":"http://..."}` JSON line.
321    pub fn has_host_root_route(&self) -> bool {
322        // Array schema: input.mock_responses[*].path
323        if let Some(arr) = self.input.get("mock_responses").and_then(|v| v.as_array()) {
324            // Direct host-root paths (/robots*, /sitemap*).
325            if arr.iter().any(|entry| {
326                entry
327                    .get("path")
328                    .and_then(|v| v.as_str())
329                    .map(is_host_root_path)
330                    .unwrap_or(false)
331            }) {
332                return true;
333            }
334            // Any response that triggers an intra-fixture redirect to a host-root path:
335            // the engine resolves the redirect target against the origin (not the
336            // /fixtures/<id>/ namespace), so the fixture must serve at host root for the
337            // follow-up GET to hit the correct route. Three trigger shapes are detected:
338            //   - 3xx with Location: /...
339            //   - any status with Refresh: <s>;url=/...
340            //   - 200 HTML with <meta http-equiv="refresh" content="...url=/...">
341            return arr.iter().any(|entry| {
342                let status = entry.get("status_code").and_then(|v| v.as_u64()).unwrap_or(0);
343                let headers = entry.get("headers").and_then(|v| v.as_object());
344                let location_redirect = (300..400).contains(&status)
345                    && headers
346                        .map(|hdrs| {
347                            hdrs.iter().any(|(name, value)| {
348                                name.eq_ignore_ascii_case("location")
349                                    && value.as_str().is_some_and(|s| s.starts_with('/'))
350                            })
351                        })
352                        .unwrap_or(false);
353                let refresh_redirect = headers
354                    .map(|hdrs| {
355                        hdrs.iter().any(|(name, value)| {
356                            if !name.eq_ignore_ascii_case("refresh") {
357                                return false;
358                            }
359                            value
360                                .as_str()
361                                .and_then(|s| s.to_ascii_lowercase().find("url=").map(|i| (s.to_owned(), i)))
362                                .map(|(s, idx)| s[idx + 4..].trim_start().starts_with('/'))
363                                .unwrap_or(false)
364                        })
365                    })
366                    .unwrap_or(false);
367                let meta_refresh = entry
368                    .get("body_inline")
369                    .and_then(|v| v.as_str())
370                    .map(|body| {
371                        let lower = body.to_ascii_lowercase();
372                        lower
373                            .split("http-equiv=\"refresh\"")
374                            .nth(1)
375                            .and_then(|s| s.split("content=").nth(1))
376                            .map(|s| s.trim_start_matches(['"', '\'']).contains("url=/"))
377                            .unwrap_or(false)
378                    })
379                    .unwrap_or(false);
380                location_redirect || refresh_redirect || meta_refresh
381            });
382        }
383        false
384    }
385
386    /// Get the resolved category (explicit or from source directory).
387    pub fn resolved_category(&self) -> String {
388        self.category.clone().unwrap_or_else(|| {
389            Path::new(&self.source)
390                .parent()
391                .and_then(|p| p.file_name())
392                .and_then(|n| n.to_str())
393                .unwrap_or("default")
394                .to_string()
395        })
396    }
397}
398
399/// Skip directive for conditionally excluding fixtures.
400#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct SkipDirective {
402    /// Languages to skip (empty means skip all).
403    #[serde(default)]
404    pub languages: Vec<String>,
405    /// Human-readable reason for skipping.
406    #[serde(default)]
407    pub reason: Option<String>,
408}
409
410impl SkipDirective {
411    /// Check if this fixture should be skipped for a given language.
412    pub fn should_skip(&self, language: &str) -> bool {
413        self.languages.is_empty() || self.languages.iter().any(|l| l == language)
414    }
415}
416
417/// A single assertion in a fixture.
418#[derive(Debug, Clone, Default, Serialize, Deserialize)]
419pub struct Assertion {
420    /// Assertion type (equals, contains, not_empty, error, etc.).
421    #[serde(rename = "type")]
422    pub assertion_type: String,
423    /// Field path to access on the result (dot-separated).
424    #[serde(default)]
425    pub field: Option<String>,
426    /// Expected value (string, number, bool, or array depending on type).
427    #[serde(default)]
428    pub value: Option<serde_json::Value>,
429    /// Expected values (for contains_all, contains_any).
430    #[serde(default)]
431    pub values: Option<Vec<serde_json::Value>>,
432    /// Method name to call on the result (for method_result assertions).
433    #[serde(default)]
434    pub method: Option<String>,
435    /// Assertion check type for the method result (equals, is_true, is_false, greater_than_or_equal, count_min).
436    #[serde(default)]
437    pub check: Option<String>,
438    /// Arguments to pass to the method call (for method_result assertions).
439    #[serde(default)]
440    pub args: Option<serde_json::Value>,
441    /// Return type hint for C method_result codegen.
442    ///
443    /// Supported values:
444    /// - `"string"` — the method returns a heap-allocated `char*` that must be
445    ///   freed with `free()` after the assertion.  The generator emits
446    ///   `char* _r = call(); assert(...); free(_r);`.
447    ///
448    /// Defaults to primitive integer dispatch when absent.
449    #[serde(default)]
450    pub return_type: Option<String>,
451}
452
453/// A group of fixtures sharing the same category.
454#[derive(Debug, Clone)]
455pub struct FixtureGroup {
456    pub category: String,
457    pub fixtures: Vec<Fixture>,
458}
459
460/// Load all fixtures from a directory recursively.
461pub fn load_fixtures(dir: &Path) -> Result<Vec<Fixture>> {
462    let mut fixtures = Vec::new();
463    load_fixtures_recursive(dir, dir, &mut fixtures)?;
464
465    // Validate: check for duplicate IDs
466    let mut seen: HashMap<String, String> = HashMap::new();
467    for f in &fixtures {
468        if let Some(prev_source) = seen.get(&f.id) {
469            bail!(
470                "duplicate fixture ID '{}': found in '{}' and '{}'",
471                f.id,
472                prev_source,
473                f.source
474            );
475        }
476        seen.insert(f.id.clone(), f.source.clone());
477    }
478
479    // Sort by (category, id) for deterministic output
480    fixtures.sort_by(|a, b| {
481        let cat_cmp = a.resolved_category().cmp(&b.resolved_category());
482        cat_cmp.then_with(|| a.id.cmp(&b.id))
483    });
484
485    Ok(fixtures)
486}
487
488fn load_fixtures_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<Fixture>) -> Result<()> {
489    let entries =
490        std::fs::read_dir(dir).with_context(|| format!("failed to read fixture directory: {}", dir.display()))?;
491
492    let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
493    paths.sort();
494
495    for path in paths {
496        if path.is_dir() {
497            load_fixtures_recursive(base, &path, fixtures)?;
498        } else if path.extension().is_some_and(|ext| ext == "json") {
499            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
500            // Skip schema files and files starting with _
501            if filename == "schema.json" || filename.starts_with('_') {
502                continue;
503            }
504            let content = std::fs::read_to_string(&path)
505                .with_context(|| format!("failed to read fixture: {}", path.display()))?;
506            let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
507
508            // Try parsing as array first, then as single fixture
509            let parsed: Vec<Fixture> = if content.trim_start().starts_with('[') {
510                serde_json::from_str(&content)
511                    .with_context(|| format!("failed to parse fixture array: {}", path.display()))?
512            } else {
513                let single: Fixture = serde_json::from_str(&content)
514                    .with_context(|| format!("failed to parse fixture: {}", path.display()))?;
515                vec![single]
516            };
517
518            for mut fixture in parsed {
519                fixture.source = relative.clone();
520                // Expand template expressions (e.g. `{{ repeat 'x' 10000 times }}`)
521                // in all JSON string values so generators emit the expanded values.
522                expand_json_templates(&mut fixture.input);
523                if let Some(ref mut http) = fixture.http {
524                    for (_, v) in http.request.headers.iter_mut() {
525                        *v = crate::escape::expand_fixture_templates(v);
526                    }
527                    if let Some(ref mut body) = http.request.body {
528                        expand_json_templates(body);
529                    }
530                }
531                fixtures.push(fixture);
532            }
533        }
534    }
535    Ok(())
536}
537
538/// Group fixtures by their resolved category.
539pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
540    let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
541    for f in fixtures {
542        groups.entry(f.resolved_category()).or_default().push(f.clone());
543    }
544    let mut result: Vec<FixtureGroup> = groups
545        .into_iter()
546        .map(|(category, fixtures)| FixtureGroup { category, fixtures })
547        .collect();
548    result.sort_by(|a, b| a.category.cmp(&b.category));
549    result
550}
551
552/// Recursively expand fixture template expressions in all string values of a JSON tree.
553fn expand_json_templates(value: &mut serde_json::Value) {
554    match value {
555        serde_json::Value::String(s) => {
556            let expanded = crate::escape::expand_fixture_templates(s);
557            if expanded != *s {
558                *s = expanded;
559            }
560        }
561        serde_json::Value::Array(arr) => {
562            for item in arr {
563                expand_json_templates(item);
564            }
565        }
566        serde_json::Value::Object(map) => {
567            for (_, v) in map.iter_mut() {
568                expand_json_templates(v);
569            }
570        }
571        _ => {}
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    #[test]
580    fn test_fixture_with_mock_response() {
581        let json = r#"{
582            "id": "test_chat",
583            "description": "Test chat",
584            "call": "chat",
585            "input": {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
586            "mock_response": {
587                "status": 200,
588                "body": {"choices": [{"message": {"content": "hello"}}]}
589            },
590            "assertions": [{"type": "not_error"}]
591        }"#;
592        let fixture: Fixture = serde_json::from_str(json).unwrap();
593        assert!(fixture.needs_mock_server());
594        assert!(!fixture.is_streaming_mock());
595        assert_eq!(fixture.mock_response.unwrap().status, 200);
596    }
597
598    #[test]
599    fn test_fixture_with_streaming_mock_response() {
600        let json = r#"{
601            "id": "test_stream",
602            "description": "Test streaming",
603            "input": {},
604            "mock_response": {
605                "status": 200,
606                "stream_chunks": [{"delta": "hello"}, {"delta": " world"}]
607            },
608            "assertions": []
609        }"#;
610        let fixture: Fixture = serde_json::from_str(json).unwrap();
611        assert!(fixture.needs_mock_server());
612        assert!(fixture.is_streaming_mock());
613    }
614
615    #[test]
616    fn test_fixture_without_mock_response() {
617        let json = r#"{
618            "id": "test_no_mock",
619            "description": "No mock",
620            "input": {},
621            "assertions": []
622        }"#;
623        let fixture: Fixture = serde_json::from_str(json).unwrap();
624        assert!(!fixture.needs_mock_server());
625        assert!(!fixture.is_streaming_mock());
626    }
627
628    #[test]
629    fn has_host_root_route_true_for_robots_path() {
630        let json = r#"{
631            "id": "robots_disallow_path",
632            "description": "Robots fixture",
633            "input": {
634                "mock_responses": [
635                    {"path": "/robots.txt", "status_code": 200, "body_inline": "User-agent: *\nDisallow: /"},
636                    {"path": "/", "status_code": 200, "body_inline": "<html/>"}
637                ]
638            },
639            "assertions": []
640        }"#;
641        let fixture: Fixture = serde_json::from_str(json).unwrap();
642        assert!(fixture.has_host_root_route(), "expected true for /robots.txt path");
643    }
644
645    #[test]
646    fn has_host_root_route_true_for_sitemap_path() {
647        let json = r#"{
648            "id": "sitemap_index",
649            "description": "Sitemap fixture",
650            "input": {
651                "mock_responses": [
652                    {"path": "/sitemap.xml", "status_code": 200, "body_inline": "<?xml version='1.0'?>"},
653                    {"path": "/", "status_code": 200, "body_inline": "<html/>"}
654                ]
655            },
656            "assertions": []
657        }"#;
658        let fixture: Fixture = serde_json::from_str(json).unwrap();
659        assert!(fixture.has_host_root_route(), "expected true for /sitemap.xml path");
660    }
661
662    #[test]
663    fn has_host_root_route_false_for_data_json_path() {
664        let json = r#"{
665            "id": "data_endpoint",
666            "description": "Non-host-root fixture",
667            "input": {
668                "mock_responses": [
669                    {"path": "/data.json", "status_code": 200, "body_inline": "{}"}
670                ]
671            },
672            "assertions": []
673        }"#;
674        let fixture: Fixture = serde_json::from_str(json).unwrap();
675        assert!(!fixture.has_host_root_route(), "expected false for /data.json path");
676    }
677
678    #[test]
679    fn has_host_root_route_false_for_single_mock_response_schema() {
680        // Single mock_response schema (no input.mock_responses array) is never host-root.
681        let json = r#"{
682            "id": "basic_chat",
683            "description": "Basic chat",
684            "mock_response": {"status": 200, "body": {}},
685            "input": {},
686            "assertions": []
687        }"#;
688        let fixture: Fixture = serde_json::from_str(json).unwrap();
689        assert!(
690            !fixture.has_host_root_route(),
691            "expected false for single mock_response schema"
692        );
693    }
694
695    #[test]
696    fn has_host_root_route_false_for_empty_mock_responses() {
697        let json = r#"{
698            "id": "empty_responses",
699            "description": "No mock_responses",
700            "input": {},
701            "assertions": []
702        }"#;
703        let fixture: Fixture = serde_json::from_str(json).unwrap();
704        assert!(!fixture.has_host_root_route(), "expected false when no mock_responses");
705    }
706}