Skip to main content

oag_node_client/emitters/
tests.rs

1use minijinja::{Environment, context};
2use oag_core::ir::{IrOperation, IrParameterLocation, IrReturnType, IrSpec, IrType};
3
4use crate::type_mapper::ir_type_to_ts;
5
6/// Emit `client.test.ts` — vitest tests for the API client.
7pub fn emit_client_tests(ir: &IrSpec) -> String {
8    let mut env = Environment::new();
9    env.set_trim_blocks(true);
10    env.add_template(
11        "client.test.ts.j2",
12        include_str!("../../templates/client.test.ts.j2"),
13    )
14    .expect("template should be valid");
15    let tmpl = env.get_template("client.test.ts.j2").unwrap();
16
17    let mut seen_methods = std::collections::HashSet::new();
18    let mut used_op_indices = std::collections::HashSet::new();
19    let operations: Vec<minijinja::Value> = ir
20        .operations
21        .iter()
22        .enumerate()
23        .flat_map(|(idx, op)| {
24            build_test_operation_contexts(op)
25                .into_iter()
26                .map(move |ctx| (idx, ctx))
27        })
28        .filter(|(idx, op)| {
29            let name = op
30                .get_attr("method_name")
31                .ok()
32                .and_then(|v| v.as_str().map(String::from));
33            match name {
34                Some(n) => {
35                    if seen_methods.insert(n) {
36                        used_op_indices.insert(*idx);
37                        true
38                    } else {
39                        false
40                    }
41                }
42                None => true,
43            }
44        })
45        .map(|(_, ctx)| ctx)
46        .collect();
47
48    // Collect type names only from operations that survived dedup
49    let type_imports: Vec<String> = collect_type_imports(
50        ir.operations
51            .iter()
52            .enumerate()
53            .filter(|(i, _)| used_op_indices.contains(i))
54            .map(|(_, op)| op),
55    );
56
57    tmpl.render(context! {
58        operations => operations,
59        type_imports => type_imports,
60    })
61    .expect("render should succeed")
62}
63
64/// Collect unique type names used in mock values across surviving operations.
65fn collect_type_imports<'a>(ops: impl Iterator<Item = &'a IrOperation>) -> Vec<String> {
66    let mut names = std::collections::BTreeSet::new();
67
68    for op in ops {
69        // Request body refs
70        if let Some(ref body) = op.request_body {
71            collect_ref_names(&body.body_type, &mut names);
72        }
73        // Return type refs (used in mock_response via guess_mock_type)
74        match &op.return_type {
75            IrReturnType::Standard(resp) => {
76                collect_ref_names(&resp.response_type, &mut names);
77            }
78            IrReturnType::Sse(sse) => {
79                collect_ref_names(&sse.event_type, &mut names);
80                if let Some(ref json_resp) = sse.json_response {
81                    collect_ref_names(&json_resp.response_type, &mut names);
82                }
83            }
84            IrReturnType::Void => {}
85        }
86    }
87
88    names.into_iter().collect()
89}
90
91fn collect_ref_names(ir_type: &IrType, names: &mut std::collections::BTreeSet<String>) {
92    match ir_type {
93        IrType::Ref(name) => {
94            names.insert(name.clone());
95        }
96        IrType::Array(inner) => collect_ref_names(inner, names),
97        IrType::Union(variants) | IrType::Intersection(variants) => {
98            for v in variants {
99                collect_ref_names(v, names);
100            }
101        }
102        _ => {}
103    }
104}
105
106fn build_test_operation_contexts(op: &IrOperation) -> Vec<minijinja::Value> {
107    let mut results = Vec::new();
108
109    match &op.return_type {
110        IrReturnType::Standard(resp) => {
111            let return_type = ir_type_to_ts(&resp.response_type);
112            results.push(build_test_context(
113                op,
114                "standard",
115                &op.name.camel_case,
116                &return_type,
117            ));
118        }
119        IrReturnType::Void => {
120            results.push(build_test_context(op, "void", &op.name.camel_case, "void"));
121        }
122        IrReturnType::Sse(sse) => {
123            let sse_name = if sse.also_has_json {
124                format!("{}Stream", op.name.camel_case)
125            } else {
126                op.name.camel_case.clone()
127            };
128            let return_type = if let Some(ref name) = sse.event_type_name {
129                name.clone()
130            } else {
131                ir_type_to_ts(&sse.event_type)
132            };
133            results.push(build_test_context(op, "sse", &sse_name, &return_type));
134
135            if let Some(ref json_resp) = sse.json_response {
136                let rt = ir_type_to_ts(&json_resp.response_type);
137                results.push(build_test_context(op, "standard", &op.name.camel_case, &rt));
138            }
139        }
140    }
141
142    results
143}
144
145fn build_test_context(
146    op: &IrOperation,
147    kind: &str,
148    method_name: &str,
149    return_type: &str,
150) -> minijinja::Value {
151    let has_body = op.request_body.is_some();
152    let test_call_args = build_test_call_args(op);
153    let expected_url_pattern = build_expected_url_pattern(op);
154    let mock_response = mock_value_ts(&if return_type == "void" {
155        IrType::Void
156    } else {
157        // Use a simple mock for the response
158        guess_mock_type(return_type)
159    });
160
161    context! {
162        kind => kind,
163        method_name => method_name,
164        http_method => op.method.as_str(),
165        return_type => return_type,
166        has_body => has_body,
167        test_call_args => test_call_args,
168        expected_url_pattern => expected_url_pattern,
169        mock_response => mock_response,
170    }
171}
172
173/// Build test call arguments for an operation.
174/// Uses a single pass over `op.parameters` to match the same order as `build_params_raw` in client.rs.
175fn build_test_call_args(op: &IrOperation) -> String {
176    let mut args = Vec::new();
177
178    for param in &op.parameters {
179        match param.location {
180            IrParameterLocation::Path => {
181                args.push(mock_value_ts(&param.param_type));
182            }
183            IrParameterLocation::Query | IrParameterLocation::Header => {
184                if param.required {
185                    args.push(mock_value_ts(&param.param_type));
186                }
187            }
188            _ => {}
189        }
190    }
191
192    if let Some(ref body) = op.request_body {
193        args.push(mock_value_ts(&body.body_type));
194    }
195
196    args.join(", ")
197}
198
199/// Build the expected URL pattern for assertions.
200fn build_expected_url_pattern(op: &IrOperation) -> String {
201    let mut path = op.path.clone();
202    for param in &op.parameters {
203        if param.location == IrParameterLocation::Path {
204            let placeholder = format!("{{{}}}", param.original_name);
205            path = path.replace(&placeholder, &mock_path_value_ts(&param.param_type));
206        }
207    }
208    path
209}
210
211/// Generate a mock TypeScript value for a given IrType.
212fn mock_value_ts(ir_type: &IrType) -> String {
213    match ir_type {
214        IrType::String | IrType::DateTime => "\"test\"".to_string(),
215        IrType::StringLiteral(s) => format!("\"{s}\""),
216        IrType::Number | IrType::Integer => "1".to_string(),
217        IrType::Boolean => "true".to_string(),
218        IrType::Null | IrType::Void => "undefined".to_string(),
219        IrType::Array(_) => "[]".to_string(),
220        IrType::Object(_) | IrType::Map(_) | IrType::Any => "{}".to_string(),
221        IrType::Ref(name) => format!("{{}} as {}", name),
222        IrType::Binary => "new Blob()".to_string(),
223        IrType::Union(variants) | IrType::Intersection(variants) => {
224            if let Some(first) = variants.first() {
225                mock_value_ts(first)
226            } else {
227                "{}".to_string()
228            }
229        }
230    }
231}
232
233/// Mock path parameter value as a string for URL patterns.
234fn mock_path_value_ts(ir_type: &IrType) -> String {
235    match ir_type {
236        IrType::Integer | IrType::Number => "1".to_string(),
237        _ => "test".to_string(),
238    }
239}
240
241/// Guess a mock IrType from a return type string for simple response mocking.
242fn guess_mock_type(return_type: &str) -> IrType {
243    match return_type {
244        "string" => IrType::String,
245        "number" => IrType::Number,
246        "boolean" => IrType::Boolean,
247        "void" => IrType::Void,
248        t if t.ends_with("[]") => IrType::Array(Box::new(IrType::Any)),
249        _ => IrType::Ref(return_type.to_string()),
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_mock_value_ts() {
259        assert_eq!(mock_value_ts(&IrType::String), "\"test\"");
260        assert_eq!(mock_value_ts(&IrType::Integer), "1");
261        assert_eq!(mock_value_ts(&IrType::Boolean), "true");
262        assert_eq!(mock_value_ts(&IrType::Void), "undefined");
263        assert_eq!(mock_value_ts(&IrType::Ref("Pet".to_string())), "{} as Pet");
264    }
265
266    #[test]
267    fn test_mock_path_value() {
268        assert_eq!(mock_path_value_ts(&IrType::Integer), "1");
269        assert_eq!(mock_path_value_ts(&IrType::String), "test");
270    }
271}