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/// Visitor specification for visitor pattern tests.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct VisitorSpec {
25    /// Map of callback method name to action.
26    pub callbacks: HashMap<String, CallbackAction>,
27}
28
29/// Action a visitor callback should take.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(tag = "action")]
32pub enum CallbackAction {
33    /// Return VisitResult::Skip.
34    #[serde(rename = "skip")]
35    Skip,
36    /// Return VisitResult::Continue.
37    #[serde(rename = "continue")]
38    Continue,
39    /// Return VisitResult::PreserveHtml.
40    #[serde(rename = "preserve_html")]
41    PreserveHtml,
42    /// Return VisitResult::Custom with static output.
43    #[serde(rename = "custom")]
44    Custom {
45        /// The static replacement string.
46        output: String,
47    },
48    /// Return VisitResult::Custom with template interpolation.
49    #[serde(rename = "custom_template")]
50    CustomTemplate {
51        /// Template with placeholders like {text}, {href}.
52        template: String,
53    },
54}
55
56/// A single e2e test fixture.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Fixture {
59    /// Unique identifier (used as test function name).
60    pub id: String,
61    /// Optional category (defaults to parent directory name).
62    #[serde(default)]
63    pub category: Option<String>,
64    /// Human-readable description.
65    pub description: String,
66    /// Optional tags for filtering.
67    #[serde(default)]
68    pub tags: Vec<String>,
69    /// Skip directive.
70    #[serde(default)]
71    pub skip: Option<SkipDirective>,
72    /// Named call config to use (references `[e2e.calls.<name>]`).
73    /// When omitted, uses the default `[e2e.call]`.
74    #[serde(default)]
75    pub call: Option<String>,
76    /// Input data passed to the function under test.
77    #[serde(default)]
78    pub input: serde_json::Value,
79    /// Optional mock HTTP response for testing HTTP clients.
80    #[serde(default)]
81    pub mock_response: Option<MockResponse>,
82    /// Optional visitor specification for visitor pattern tests.
83    #[serde(default)]
84    pub visitor: Option<VisitorSpec>,
85    /// List of assertions to check.
86    #[serde(default)]
87    pub assertions: Vec<Assertion>,
88    /// Source file path (populated during loading).
89    #[serde(skip)]
90    pub source: String,
91    /// HTTP server test specification. When present, this fixture tests
92    /// an HTTP handler rather than a function call.
93    #[serde(default)]
94    pub http: Option<HttpFixture>,
95}
96
97/// HTTP server test specification.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct HttpFixture {
100    /// Handler/route definition.
101    pub handler: HttpHandler,
102    /// The HTTP request to send.
103    pub request: HttpRequest,
104    /// Expected response.
105    pub expected_response: HttpExpectedResponse,
106}
107
108/// Handler/route definition for HTTP server tests.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct HttpHandler {
111    /// Route pattern (e.g., "/users/{user_id}").
112    pub route: String,
113    /// HTTP method (GET, POST, PUT, etc.).
114    pub method: String,
115    /// JSON Schema for request body validation.
116    #[serde(default)]
117    pub body_schema: Option<serde_json::Value>,
118    /// Parameter schemas by source (path, query, header, cookie).
119    #[serde(default)]
120    pub parameters: HashMap<String, HashMap<String, serde_json::Value>>,
121    /// Middleware configuration.
122    #[serde(default)]
123    pub middleware: Option<HttpMiddleware>,
124}
125
126/// HTTP request to send in a server test.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct HttpRequest {
129    pub method: String,
130    pub path: String,
131    #[serde(default)]
132    pub headers: HashMap<String, String>,
133    #[serde(default)]
134    pub query_params: HashMap<String, serde_json::Value>,
135    #[serde(default)]
136    pub cookies: HashMap<String, String>,
137    #[serde(default)]
138    pub body: Option<serde_json::Value>,
139    #[serde(default)]
140    pub content_type: Option<String>,
141}
142
143/// Expected HTTP response specification.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct HttpExpectedResponse {
146    pub status_code: u16,
147    /// Exact body match.
148    #[serde(default)]
149    pub body: Option<serde_json::Value>,
150    /// Partial body match (only check specified fields).
151    #[serde(default)]
152    pub body_partial: Option<serde_json::Value>,
153    /// Header expectations. Special tokens: `<<uuid>>`, `<<present>>`, `<<absent>>`.
154    #[serde(default)]
155    pub headers: HashMap<String, String>,
156    /// Expected validation errors (for 422 responses).
157    #[serde(default)]
158    pub validation_errors: Option<Vec<ValidationErrorExpectation>>,
159}
160
161/// Expected validation error entry.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct ValidationErrorExpectation {
164    pub loc: Vec<String>,
165    pub msg: String,
166    #[serde(rename = "type")]
167    pub error_type: String,
168}
169
170/// Middleware configuration for HTTP handler tests.
171#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172pub struct HttpMiddleware {
173    #[serde(default)]
174    pub jwt_auth: Option<serde_json::Value>,
175    #[serde(default)]
176    pub api_key_auth: Option<serde_json::Value>,
177    #[serde(default)]
178    pub compression: Option<serde_json::Value>,
179    #[serde(default)]
180    pub rate_limit: Option<serde_json::Value>,
181    #[serde(default)]
182    pub request_timeout: Option<serde_json::Value>,
183    #[serde(default)]
184    pub request_id: Option<serde_json::Value>,
185}
186
187impl Fixture {
188    /// Returns true if this is an HTTP server test fixture.
189    pub fn is_http_test(&self) -> bool {
190        self.http.is_some()
191    }
192
193    /// Returns true if this fixture requires a mock HTTP server.
194    pub fn needs_mock_server(&self) -> bool {
195        self.mock_response.is_some()
196    }
197
198    /// Returns true if the mock response uses streaming (SSE).
199    pub fn is_streaming_mock(&self) -> bool {
200        self.mock_response
201            .as_ref()
202            .and_then(|m| m.stream_chunks.as_ref())
203            .map(|c| !c.is_empty())
204            .unwrap_or(false)
205    }
206
207    /// Get the resolved category (explicit or from source directory).
208    pub fn resolved_category(&self) -> String {
209        self.category.clone().unwrap_or_else(|| {
210            Path::new(&self.source)
211                .parent()
212                .and_then(|p| p.file_name())
213                .and_then(|n| n.to_str())
214                .unwrap_or("default")
215                .to_string()
216        })
217    }
218}
219
220/// Skip directive for conditionally excluding fixtures.
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct SkipDirective {
223    /// Languages to skip (empty means skip all).
224    #[serde(default)]
225    pub languages: Vec<String>,
226    /// Human-readable reason for skipping.
227    #[serde(default)]
228    pub reason: Option<String>,
229}
230
231impl SkipDirective {
232    /// Check if this fixture should be skipped for a given language.
233    pub fn should_skip(&self, language: &str) -> bool {
234        self.languages.is_empty() || self.languages.iter().any(|l| l == language)
235    }
236}
237
238/// A single assertion in a fixture.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct Assertion {
241    /// Assertion type (equals, contains, not_empty, error, etc.).
242    #[serde(rename = "type")]
243    pub assertion_type: String,
244    /// Field path to access on the result (dot-separated).
245    #[serde(default)]
246    pub field: Option<String>,
247    /// Expected value (string, number, bool, or array depending on type).
248    #[serde(default)]
249    pub value: Option<serde_json::Value>,
250    /// Expected values (for contains_all, contains_any).
251    #[serde(default)]
252    pub values: Option<Vec<serde_json::Value>>,
253    /// Method name to call on the result (for method_result assertions).
254    #[serde(default)]
255    pub method: Option<String>,
256    /// Assertion check type for the method result (equals, is_true, is_false, greater_than_or_equal, count_min).
257    #[serde(default)]
258    pub check: Option<String>,
259    /// Arguments to pass to the method call (for method_result assertions).
260    #[serde(default)]
261    pub args: Option<serde_json::Value>,
262}
263
264/// A group of fixtures sharing the same category.
265#[derive(Debug, Clone)]
266pub struct FixtureGroup {
267    pub category: String,
268    pub fixtures: Vec<Fixture>,
269}
270
271/// Load all fixtures from a directory recursively.
272pub fn load_fixtures(dir: &Path) -> Result<Vec<Fixture>> {
273    let mut fixtures = Vec::new();
274    load_fixtures_recursive(dir, dir, &mut fixtures)?;
275
276    // Validate: check for duplicate IDs
277    let mut seen: HashMap<String, String> = HashMap::new();
278    for f in &fixtures {
279        if let Some(prev_source) = seen.get(&f.id) {
280            bail!(
281                "duplicate fixture ID '{}': found in '{}' and '{}'",
282                f.id,
283                prev_source,
284                f.source
285            );
286        }
287        seen.insert(f.id.clone(), f.source.clone());
288    }
289
290    // Sort by (category, id) for deterministic output
291    fixtures.sort_by(|a, b| {
292        let cat_cmp = a.resolved_category().cmp(&b.resolved_category());
293        cat_cmp.then_with(|| a.id.cmp(&b.id))
294    });
295
296    Ok(fixtures)
297}
298
299fn load_fixtures_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<Fixture>) -> Result<()> {
300    let entries =
301        std::fs::read_dir(dir).with_context(|| format!("failed to read fixture directory: {}", dir.display()))?;
302
303    let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
304    paths.sort();
305
306    for path in paths {
307        if path.is_dir() {
308            load_fixtures_recursive(base, &path, fixtures)?;
309        } else if path.extension().is_some_and(|ext| ext == "json") {
310            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
311            // Skip schema files and files starting with _
312            if filename == "schema.json" || filename.starts_with('_') {
313                continue;
314            }
315            let content = std::fs::read_to_string(&path)
316                .with_context(|| format!("failed to read fixture: {}", path.display()))?;
317            let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
318
319            // Try parsing as array first, then as single fixture
320            let parsed: Vec<Fixture> = if content.trim_start().starts_with('[') {
321                serde_json::from_str(&content)
322                    .with_context(|| format!("failed to parse fixture array: {}", path.display()))?
323            } else {
324                let single: Fixture = serde_json::from_str(&content)
325                    .with_context(|| format!("failed to parse fixture: {}", path.display()))?;
326                vec![single]
327            };
328
329            for mut fixture in parsed {
330                fixture.source = relative.clone();
331                fixtures.push(fixture);
332            }
333        }
334    }
335    Ok(())
336}
337
338/// Group fixtures by their resolved category.
339pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
340    let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
341    for f in fixtures {
342        groups.entry(f.resolved_category()).or_default().push(f.clone());
343    }
344    let mut result: Vec<FixtureGroup> = groups
345        .into_iter()
346        .map(|(category, fixtures)| FixtureGroup { category, fixtures })
347        .collect();
348    result.sort_by(|a, b| a.category.cmp(&b.category));
349    result
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn test_fixture_with_mock_response() {
358        let json = r#"{
359            "id": "test_chat",
360            "description": "Test chat",
361            "call": "chat",
362            "input": {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
363            "mock_response": {
364                "status": 200,
365                "body": {"choices": [{"message": {"content": "hello"}}]}
366            },
367            "assertions": [{"type": "not_error"}]
368        }"#;
369        let fixture: Fixture = serde_json::from_str(json).unwrap();
370        assert!(fixture.needs_mock_server());
371        assert!(!fixture.is_streaming_mock());
372        assert_eq!(fixture.mock_response.unwrap().status, 200);
373    }
374
375    #[test]
376    fn test_fixture_with_streaming_mock_response() {
377        let json = r#"{
378            "id": "test_stream",
379            "description": "Test streaming",
380            "input": {},
381            "mock_response": {
382                "status": 200,
383                "stream_chunks": [{"delta": "hello"}, {"delta": " world"}]
384            },
385            "assertions": []
386        }"#;
387        let fixture: Fixture = serde_json::from_str(json).unwrap();
388        assert!(fixture.needs_mock_server());
389        assert!(fixture.is_streaming_mock());
390    }
391
392    #[test]
393    fn test_fixture_without_mock_response() {
394        let json = r#"{
395            "id": "test_no_mock",
396            "description": "No mock",
397            "input": {},
398            "assertions": []
399        }"#;
400        let fixture: Fixture = serde_json::from_str(json).unwrap();
401        assert!(!fixture.needs_mock_server());
402        assert!(!fixture.is_streaming_mock());
403    }
404}