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            call: None,
147            input: serde_json::Value::Null,
148            mock_response: None,
149            visitor: None,
150            assertions: vec![],
151            source: String::new(),
152            http: Some(HttpFixture {
153                handler: crate::fixture::HttpHandler {
154                    route: format!("/fixtures/{id}"),
155                    method: "GET".into(),
156                    body_schema: None,
157                    parameters: HashMap::new(),
158                    middleware: None,
159                },
160                request: HttpRequest {
161                    method: "GET".into(),
162                    path: format!("/fixtures/{id}"),
163                    headers: HashMap::new(),
164                    query_params: HashMap::new(),
165                    cookies: HashMap::new(),
166                    body: None,
167                    content_type: None,
168                },
169                expected_response: expected,
170            }),
171        }
172    }
173
174    fn empty_expected(status: u16) -> HttpExpectedResponse {
175        HttpExpectedResponse {
176            status_code: status,
177            body: None,
178            body_partial: None,
179            headers: HashMap::new(),
180            validation_errors: None,
181        }
182    }
183
184    #[test]
185    fn driver_emits_open_call_status_close_in_order() {
186        let fixture = http_fixture("simple", empty_expected(200));
187        let mut out = String::new();
188        let emitted = render_http_test(&mut out, &TagRenderer, &fixture);
189        assert!(emitted);
190        assert_eq!(
191            out,
192            "OPEN(simple)\nCALL(GET /fixtures/simple -> response)\nSTATUS=200\nCLOSE\n"
193        );
194    }
195
196    #[test]
197    fn driver_skips_when_no_http_block() {
198        let mut fixture = http_fixture("noop", empty_expected(200));
199        fixture.http = None;
200        let mut out = String::new();
201        let emitted = render_http_test(&mut out, &TagRenderer, &fixture);
202        assert!(!emitted);
203        assert!(out.is_empty());
204    }
205
206    #[test]
207    fn driver_emits_skip_marker_and_short_circuits_assertions() {
208        let mut fixture = http_fixture("skipme", empty_expected(200));
209        fixture.skip = Some(crate::fixture::SkipDirective {
210            languages: vec!["mock".into()],
211            reason: Some("not yet".into()),
212        });
213        let mut out = String::new();
214        render_http_test(&mut out, &TagRenderer, &fixture);
215        assert!(out.contains("OPEN(skipme|skip=not yet)"));
216        assert!(out.contains("CLOSE"));
217        assert!(!out.contains("CALL"));
218        assert!(!out.contains("STATUS"));
219    }
220
221    #[test]
222    fn driver_strips_content_encoding_header_assertion() {
223        let mut expected = empty_expected(200);
224        expected.headers.insert("Content-Encoding".into(), "gzip".into());
225        expected.headers.insert("X-Foo".into(), "bar".into());
226        let fixture = http_fixture("hdr", expected);
227        let mut out = String::new();
228        render_http_test(&mut out, &TagRenderer, &fixture);
229        assert!(!out.contains("HEADER(Content-Encoding"));
230        assert!(out.contains("HEADER(X-Foo=bar)"));
231    }
232
233    #[test]
234    fn driver_emits_headers_in_sorted_order() {
235        let mut expected = empty_expected(200);
236        expected.headers.insert("Z-Header".into(), "z".into());
237        expected.headers.insert("A-Header".into(), "a".into());
238        expected.headers.insert("M-Header".into(), "m".into());
239        let fixture = http_fixture("hdr", expected);
240        let mut out = String::new();
241        render_http_test(&mut out, &TagRenderer, &fixture);
242        let a_pos = out.find("HEADER(A-Header").unwrap();
243        let m_pos = out.find("HEADER(M-Header").unwrap();
244        let z_pos = out.find("HEADER(Z-Header").unwrap();
245        assert!(a_pos < m_pos);
246        assert!(m_pos < z_pos);
247    }
248
249    #[test]
250    fn driver_skips_body_assert_for_null_and_empty_string_sentinels() {
251        let mut expected = empty_expected(200);
252        expected.body = Some(serde_json::Value::Null);
253        let fixture = http_fixture("nullbody", expected);
254        let mut out = String::new();
255        render_http_test(&mut out, &TagRenderer, &fixture);
256        assert!(!out.contains("JSON_BODY"));
257
258        let mut expected = empty_expected(200);
259        expected.body = Some(serde_json::Value::String(String::new()));
260        let fixture = http_fixture("emptybody", expected);
261        let mut out = String::new();
262        render_http_test(&mut out, &TagRenderer, &fixture);
263        assert!(!out.contains("JSON_BODY"));
264    }
265
266    #[test]
267    fn driver_emits_body_partial_assertion_independently_of_body() {
268        let mut expected = empty_expected(200);
269        expected.body_partial = Some(serde_json::json!({"k": "v"}));
270        let fixture = http_fixture("partial", expected);
271        let mut out = String::new();
272        render_http_test(&mut out, &TagRenderer, &fixture);
273        assert!(out.contains("PARTIAL_BODY"));
274    }
275
276    #[test]
277    fn driver_emits_validation_errors_assertion_when_present_and_nonempty() {
278        let mut expected = empty_expected(422);
279        expected.validation_errors = Some(vec![ValidationErrorExpectation {
280            loc: vec!["name".into()],
281            msg: "field required".into(),
282            error_type: "missing".into(),
283        }]);
284        let fixture = http_fixture("ve", expected);
285        let mut out = String::new();
286        render_http_test(&mut out, &TagRenderer, &fixture);
287        assert!(out.contains("VALIDATION(1)"));
288
289        // Empty vec → no assertion
290        let mut expected = empty_expected(422);
291        expected.validation_errors = Some(vec![]);
292        let fixture = http_fixture("ve_empty", expected);
293        let mut out = String::new();
294        render_http_test(&mut out, &TagRenderer, &fixture);
295        assert!(!out.contains("VALIDATION"));
296    }
297}