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}
21
22/// A single e2e test fixture.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Fixture {
25    /// Unique identifier (used as test function name).
26    pub id: String,
27    /// Optional category (defaults to parent directory name).
28    #[serde(default)]
29    pub category: Option<String>,
30    /// Human-readable description.
31    pub description: String,
32    /// Optional tags for filtering.
33    #[serde(default)]
34    pub tags: Vec<String>,
35    /// Skip directive.
36    #[serde(default)]
37    pub skip: Option<SkipDirective>,
38    /// Named call config to use (references `[e2e.calls.<name>]`).
39    /// When omitted, uses the default `[e2e.call]`.
40    #[serde(default)]
41    pub call: Option<String>,
42    /// Input data passed to the function under test.
43    #[serde(default)]
44    pub input: serde_json::Value,
45    /// Optional mock HTTP response for testing HTTP clients.
46    #[serde(default)]
47    pub mock_response: Option<MockResponse>,
48    /// List of assertions to check.
49    #[serde(default)]
50    pub assertions: Vec<Assertion>,
51    /// Source file path (populated during loading).
52    #[serde(skip)]
53    pub source: String,
54}
55
56impl Fixture {
57    /// Returns true if this fixture requires a mock HTTP server.
58    pub fn needs_mock_server(&self) -> bool {
59        self.mock_response.is_some()
60    }
61
62    /// Returns true if the mock response uses streaming (SSE).
63    pub fn is_streaming_mock(&self) -> bool {
64        self.mock_response
65            .as_ref()
66            .and_then(|m| m.stream_chunks.as_ref())
67            .map(|c| !c.is_empty())
68            .unwrap_or(false)
69    }
70
71    /// Get the resolved category (explicit or from source directory).
72    pub fn resolved_category(&self) -> String {
73        self.category.clone().unwrap_or_else(|| {
74            Path::new(&self.source)
75                .parent()
76                .and_then(|p| p.file_name())
77                .and_then(|n| n.to_str())
78                .unwrap_or("default")
79                .to_string()
80        })
81    }
82}
83
84/// Skip directive for conditionally excluding fixtures.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct SkipDirective {
87    /// Languages to skip (empty means skip all).
88    #[serde(default)]
89    pub languages: Vec<String>,
90    /// Human-readable reason for skipping.
91    #[serde(default)]
92    pub reason: Option<String>,
93}
94
95impl SkipDirective {
96    /// Check if this fixture should be skipped for a given language.
97    pub fn should_skip(&self, language: &str) -> bool {
98        self.languages.is_empty() || self.languages.iter().any(|l| l == language)
99    }
100}
101
102/// A single assertion in a fixture.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct Assertion {
105    /// Assertion type (equals, contains, not_empty, error, etc.).
106    #[serde(rename = "type")]
107    pub assertion_type: String,
108    /// Field path to access on the result (dot-separated).
109    #[serde(default)]
110    pub field: Option<String>,
111    /// Expected value (string, number, bool, or array depending on type).
112    #[serde(default)]
113    pub value: Option<serde_json::Value>,
114    /// Expected values (for contains_all, contains_any).
115    #[serde(default)]
116    pub values: Option<Vec<serde_json::Value>>,
117}
118
119/// A group of fixtures sharing the same category.
120#[derive(Debug, Clone)]
121pub struct FixtureGroup {
122    pub category: String,
123    pub fixtures: Vec<Fixture>,
124}
125
126/// Load all fixtures from a directory recursively.
127pub fn load_fixtures(dir: &Path) -> Result<Vec<Fixture>> {
128    let mut fixtures = Vec::new();
129    load_fixtures_recursive(dir, dir, &mut fixtures)?;
130
131    // Validate: check for duplicate IDs
132    let mut seen: HashMap<String, String> = HashMap::new();
133    for f in &fixtures {
134        if let Some(prev_source) = seen.get(&f.id) {
135            bail!(
136                "duplicate fixture ID '{}': found in '{}' and '{}'",
137                f.id,
138                prev_source,
139                f.source
140            );
141        }
142        seen.insert(f.id.clone(), f.source.clone());
143    }
144
145    // Sort by (category, id) for deterministic output
146    fixtures.sort_by(|a, b| {
147        let cat_cmp = a.resolved_category().cmp(&b.resolved_category());
148        cat_cmp.then_with(|| a.id.cmp(&b.id))
149    });
150
151    Ok(fixtures)
152}
153
154fn load_fixtures_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<Fixture>) -> Result<()> {
155    let entries =
156        std::fs::read_dir(dir).with_context(|| format!("failed to read fixture directory: {}", dir.display()))?;
157
158    let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
159    paths.sort();
160
161    for path in paths {
162        if path.is_dir() {
163            load_fixtures_recursive(base, &path, fixtures)?;
164        } else if path.extension().is_some_and(|ext| ext == "json") {
165            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
166            // Skip schema files and files starting with _
167            if filename == "schema.json" || filename.starts_with('_') {
168                continue;
169            }
170            let content = std::fs::read_to_string(&path)
171                .with_context(|| format!("failed to read fixture: {}", path.display()))?;
172            let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
173
174            // Try parsing as array first, then as single fixture
175            let parsed: Vec<Fixture> = if content.trim_start().starts_with('[') {
176                serde_json::from_str(&content)
177                    .with_context(|| format!("failed to parse fixture array: {}", path.display()))?
178            } else {
179                let single: Fixture = serde_json::from_str(&content)
180                    .with_context(|| format!("failed to parse fixture: {}", path.display()))?;
181                vec![single]
182            };
183
184            for mut fixture in parsed {
185                fixture.source = relative.clone();
186                fixtures.push(fixture);
187            }
188        }
189    }
190    Ok(())
191}
192
193/// Group fixtures by their resolved category.
194pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
195    let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
196    for f in fixtures {
197        groups.entry(f.resolved_category()).or_default().push(f.clone());
198    }
199    let mut result: Vec<FixtureGroup> = groups
200        .into_iter()
201        .map(|(category, fixtures)| FixtureGroup { category, fixtures })
202        .collect();
203    result.sort_by(|a, b| a.category.cmp(&b.category));
204    result
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_fixture_with_mock_response() {
213        let json = r#"{
214            "id": "test_chat",
215            "description": "Test chat",
216            "call": "chat",
217            "input": {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
218            "mock_response": {
219                "status": 200,
220                "body": {"choices": [{"message": {"content": "hello"}}]}
221            },
222            "assertions": [{"type": "not_error"}]
223        }"#;
224        let fixture: Fixture = serde_json::from_str(json).unwrap();
225        assert!(fixture.needs_mock_server());
226        assert!(!fixture.is_streaming_mock());
227        assert_eq!(fixture.mock_response.unwrap().status, 200);
228    }
229
230    #[test]
231    fn test_fixture_with_streaming_mock_response() {
232        let json = r#"{
233            "id": "test_stream",
234            "description": "Test streaming",
235            "input": {},
236            "mock_response": {
237                "status": 200,
238                "stream_chunks": [{"delta": "hello"}, {"delta": " world"}]
239            },
240            "assertions": []
241        }"#;
242        let fixture: Fixture = serde_json::from_str(json).unwrap();
243        assert!(fixture.needs_mock_server());
244        assert!(fixture.is_streaming_mock());
245    }
246
247    #[test]
248    fn test_fixture_without_mock_response() {
249        let json = r#"{
250            "id": "test_no_mock",
251            "description": "No mock",
252            "input": {},
253            "assertions": []
254        }"#;
255        let fixture: Fixture = serde_json::from_str(json).unwrap();
256        assert!(!fixture.needs_mock_server());
257        assert!(!fixture.is_streaming_mock());
258    }
259}