Skip to main content

alef_e2e/codegen/client/
http_call.rs

1//! Shared HTTP-test driver.
2//!
3//! Calls trait primitives on a [`TestClientRenderer`] in the canonical order
4//! a TestClient-driven test takes:
5//!
6//! 1. `render_test_open` — doc, signature, opening brace, language-native skip annotation.
7//! 2. `render_call` — `let response = client.METHOD(...)`.
8//! 3. `render_assert_status` — status code assertion.
9//! 4. `render_assert_header` (per header) — header assertions.
10//! 5. `render_assert_json_body` / `render_assert_partial_body` — body assertion.
11//! 6. `render_assert_validation_errors` — 422 validation errors, if present.
12//! 7. `render_test_close` — closing brace / `end`.
13//!
14//! Steps 3-6 are skipped automatically when the corresponding expectation is empty.
15
16use super::{CallCtx, TestClientRenderer, has_meaningful_body, is_skipped};
17use crate::fixture::Fixture;
18
19/// Default name for the response binding inside a generated test.
20pub const DEFAULT_RESPONSE_VAR: &str = "response";
21
22/// Render a single HTTP test for `fixture` to `out` using `renderer`.
23///
24/// Returns `true` if a test was emitted (the fixture has an `http` block),
25/// `false` otherwise — caller is responsible for handling non-HTTP fixtures
26/// (WebSocket, AsyncAPI spec validation, etc.) via different drivers.
27pub fn render_http_test<R: TestClientRenderer + ?Sized>(out: &mut String, renderer: &R, fixture: &Fixture) -> bool {
28    let Some(http) = fixture.http.as_ref() else {
29        return false;
30    };
31
32    let fn_name = renderer.sanitize_test_name(&fixture.id);
33
34    let skip_reason = if is_skipped(fixture, renderer.language_name()) {
35        Some(
36            fixture
37                .skip
38                .as_ref()
39                .and_then(|s| s.reason.as_deref())
40                .unwrap_or("skipped"),
41        )
42    } else {
43        None
44    };
45
46    renderer.render_test_open(out, &fn_name, &fixture.description, skip_reason);
47
48    if skip_reason.is_some() {
49        // For some languages, render_test_open already emitted a stub body; in
50        // those cases render_test_close is still required for symmetry. Calls
51        // below are gated on the renderer's expectations.
52        renderer.render_test_close(out);
53        return true;
54    }
55
56    let response_var = DEFAULT_RESPONSE_VAR;
57    let ctx = CallCtx::from_request(&http.request, response_var);
58    renderer.render_call(out, &ctx);
59
60    renderer.render_assert_status(out, response_var, http.expected_response.status_code);
61
62    // Emit header assertions in deterministic (sorted) order so generated
63    // output is stable across cargo invocations.
64    let mut header_names: Vec<&String> = http.expected_response.headers.keys().collect();
65    header_names.sort();
66    for name in header_names {
67        let value = &http.expected_response.headers[name];
68        if name.eq_ignore_ascii_case("content-encoding") {
69            // Mock layer strips Content-Encoding before delivering the body;
70            // asserting on it is a known false-positive source.
71            continue;
72        }
73        renderer.render_assert_header(out, response_var, name, value);
74    }
75
76    if has_meaningful_body(&http.expected_response) {
77        if let Some(body) = http.expected_response.body.as_ref() {
78            renderer.render_assert_json_body(out, response_var, body);
79        }
80    }
81
82    if let Some(partial) = http.expected_response.body_partial.as_ref() {
83        renderer.render_assert_partial_body(out, response_var, partial);
84    }
85
86    if let Some(errors) = http.expected_response.validation_errors.as_ref() {
87        if !errors.is_empty() {
88            renderer.render_assert_validation_errors(out, response_var, errors);
89        }
90    }
91
92    renderer.render_test_close(out);
93    true
94}
95
96#[cfg(test)]
97mod tests {
98    use super::super::{CallCtx, TestClientRenderer};
99    use super::render_http_test;
100    use crate::fixture::{Fixture, HttpExpectedResponse, HttpFixture, HttpRequest, ValidationErrorExpectation};
101    use std::collections::HashMap;
102
103    /// Mock renderer that records every call as a tag in `out`. Lets us assert
104    /// the exact sequence of trait calls the shared driver makes for each
105    /// expected-response shape.
106    struct TagRenderer;
107
108    impl TestClientRenderer for TagRenderer {
109        fn language_name(&self) -> &'static str {
110            "mock"
111        }
112        fn render_test_open(&self, out: &mut String, fn_name: &str, _: &str, skip: Option<&str>) {
113            let skip_marker = skip.map(|r| format!("|skip={r}")).unwrap_or_default();
114            out.push_str(&format!("OPEN({fn_name}{skip_marker})\n"));
115        }
116        fn render_test_close(&self, out: &mut String) {
117            out.push_str("CLOSE\n");
118        }
119        fn render_call(&self, out: &mut String, ctx: &CallCtx<'_>) {
120            out.push_str(&format!("CALL({} {} -> {})\n", ctx.method, ctx.path, ctx.response_var));
121        }
122        fn render_assert_status(&self, out: &mut String, _: &str, status: u16) {
123            out.push_str(&format!("STATUS={status}\n"));
124        }
125        fn render_assert_header(&self, out: &mut String, _: &str, name: &str, value: &str) {
126            out.push_str(&format!("HEADER({name}={value})\n"));
127        }
128        fn render_assert_json_body(&self, out: &mut String, _: &str, expected: &serde_json::Value) {
129            out.push_str(&format!("JSON_BODY({expected})\n"));
130        }
131        fn render_assert_partial_body(&self, out: &mut String, _: &str, expected: &serde_json::Value) {
132            out.push_str(&format!("PARTIAL_BODY({expected})\n"));
133        }
134        fn render_assert_validation_errors(&self, out: &mut String, _: &str, errors: &[ValidationErrorExpectation]) {
135            out.push_str(&format!("VALIDATION({})\n", errors.len()));
136        }
137    }
138
139    fn http_fixture(id: &str, expected: HttpExpectedResponse) -> Fixture {
140        Fixture {
141            id: id.into(),
142            description: "test".into(),
143            category: Some("smoke".into()),
144            tags: vec![],
145            skip: None,
146            env: None,
147            call: None,
148            input: serde_json::Value::Null,
149            mock_response: None,
150            visitor: None,
151            assertions: vec![],
152            source: String::new(),
153            http: Some(HttpFixture {
154                handler: crate::fixture::HttpHandler {
155                    route: format!("/fixtures/{id}"),
156                    method: "GET".into(),
157                    body_schema: None,
158                    parameters: HashMap::new(),
159                    middleware: None,
160                },
161                request: HttpRequest {
162                    method: "GET".into(),
163                    path: format!("/fixtures/{id}"),
164                    headers: HashMap::new(),
165                    query_params: HashMap::new(),
166                    cookies: HashMap::new(),
167                    body: None,
168                    content_type: None,
169                },
170                expected_response: expected,
171            }),
172        }
173    }
174
175    fn empty_expected(status: u16) -> HttpExpectedResponse {
176        HttpExpectedResponse {
177            status_code: status,
178            body: None,
179            body_partial: None,
180            headers: HashMap::new(),
181            validation_errors: None,
182        }
183    }
184
185    #[test]
186    fn driver_emits_open_call_status_close_in_order() {
187        let fixture = http_fixture("simple", empty_expected(200));
188        let mut out = String::new();
189        let emitted = render_http_test(&mut out, &TagRenderer, &fixture);
190        assert!(emitted);
191        assert_eq!(
192            out,
193            "OPEN(simple)\nCALL(GET /fixtures/simple -> response)\nSTATUS=200\nCLOSE\n"
194        );
195    }
196
197    #[test]
198    fn driver_skips_when_no_http_block() {
199        let mut fixture = http_fixture("noop", empty_expected(200));
200        fixture.http = None;
201        let mut out = String::new();
202        let emitted = render_http_test(&mut out, &TagRenderer, &fixture);
203        assert!(!emitted);
204        assert!(out.is_empty());
205    }
206
207    #[test]
208    fn driver_emits_skip_marker_and_short_circuits_assertions() {
209        let mut fixture = http_fixture("skipme", empty_expected(200));
210        fixture.skip = Some(crate::fixture::SkipDirective {
211            languages: vec!["mock".into()],
212            reason: Some("not yet".into()),
213        });
214        let mut out = String::new();
215        render_http_test(&mut out, &TagRenderer, &fixture);
216        assert!(out.contains("OPEN(skipme|skip=not yet)"));
217        assert!(out.contains("CLOSE"));
218        assert!(!out.contains("CALL"));
219        assert!(!out.contains("STATUS"));
220    }
221
222    #[test]
223    fn driver_strips_content_encoding_header_assertion() {
224        let mut expected = empty_expected(200);
225        expected.headers.insert("Content-Encoding".into(), "gzip".into());
226        expected.headers.insert("X-Foo".into(), "bar".into());
227        let fixture = http_fixture("hdr", expected);
228        let mut out = String::new();
229        render_http_test(&mut out, &TagRenderer, &fixture);
230        assert!(!out.contains("HEADER(Content-Encoding"));
231        assert!(out.contains("HEADER(X-Foo=bar)"));
232    }
233
234    #[test]
235    fn driver_emits_headers_in_sorted_order() {
236        let mut expected = empty_expected(200);
237        expected.headers.insert("Z-Header".into(), "z".into());
238        expected.headers.insert("A-Header".into(), "a".into());
239        expected.headers.insert("M-Header".into(), "m".into());
240        let fixture = http_fixture("hdr", expected);
241        let mut out = String::new();
242        render_http_test(&mut out, &TagRenderer, &fixture);
243        let a_pos = out.find("HEADER(A-Header").unwrap();
244        let m_pos = out.find("HEADER(M-Header").unwrap();
245        let z_pos = out.find("HEADER(Z-Header").unwrap();
246        assert!(a_pos < m_pos);
247        assert!(m_pos < z_pos);
248    }
249
250    #[test]
251    fn driver_skips_body_assert_for_null_and_empty_string_sentinels() {
252        let mut expected = empty_expected(200);
253        expected.body = Some(serde_json::Value::Null);
254        let fixture = http_fixture("nullbody", expected);
255        let mut out = String::new();
256        render_http_test(&mut out, &TagRenderer, &fixture);
257        assert!(!out.contains("JSON_BODY"));
258
259        let mut expected = empty_expected(200);
260        expected.body = Some(serde_json::Value::String(String::new()));
261        let fixture = http_fixture("emptybody", expected);
262        let mut out = String::new();
263        render_http_test(&mut out, &TagRenderer, &fixture);
264        assert!(!out.contains("JSON_BODY"));
265    }
266
267    #[test]
268    fn driver_emits_body_partial_assertion_independently_of_body() {
269        let mut expected = empty_expected(200);
270        expected.body_partial = Some(serde_json::json!({"k": "v"}));
271        let fixture = http_fixture("partial", expected);
272        let mut out = String::new();
273        render_http_test(&mut out, &TagRenderer, &fixture);
274        assert!(out.contains("PARTIAL_BODY"));
275    }
276
277    #[test]
278    fn driver_emits_validation_errors_assertion_when_present_and_nonempty() {
279        let mut expected = empty_expected(422);
280        expected.validation_errors = Some(vec![ValidationErrorExpectation {
281            loc: vec!["name".into()],
282            msg: "field required".into(),
283            error_type: "missing".into(),
284        }]);
285        let fixture = http_fixture("ve", expected);
286        let mut out = String::new();
287        render_http_test(&mut out, &TagRenderer, &fixture);
288        assert!(out.contains("VALIDATION(1)"));
289
290        // Empty vec → no assertion
291        let mut expected = empty_expected(422);
292        expected.validation_errors = Some(vec![]);
293        let fixture = http_fixture("ve_empty", expected);
294        let mut out = String::new();
295        render_http_test(&mut out, &TagRenderer, &fixture);
296        assert!(!out.contains("VALIDATION"));
297    }
298}