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) => {
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.
174fn build_test_call_args(op: &IrOperation) -> String {
175    let mut args = Vec::new();
176
177    for param in &op.parameters {
178        if param.location == IrParameterLocation::Path {
179            args.push(mock_value_ts(&param.param_type));
180        }
181    }
182
183    for param in &op.parameters {
184        if param.location == IrParameterLocation::Query && param.required {
185            args.push(mock_value_ts(&param.param_type));
186        }
187    }
188
189    if let Some(ref body) = op.request_body {
190        args.push(mock_value_ts(&body.body_type));
191    }
192
193    args.join(", ")
194}
195
196/// Build the expected URL pattern for assertions.
197fn build_expected_url_pattern(op: &IrOperation) -> String {
198    let mut path = op.path.clone();
199    for param in &op.parameters {
200        if param.location == IrParameterLocation::Path {
201            let placeholder = format!("{{{}}}", param.original_name);
202            path = path.replace(&placeholder, &mock_path_value_ts(&param.param_type));
203        }
204    }
205    path
206}
207
208/// Generate a mock TypeScript value for a given IrType.
209fn mock_value_ts(ir_type: &IrType) -> String {
210    match ir_type {
211        IrType::String | IrType::DateTime => "\"test\"".to_string(),
212        IrType::StringLiteral(s) => format!("\"{s}\""),
213        IrType::Number | IrType::Integer => "1".to_string(),
214        IrType::Boolean => "true".to_string(),
215        IrType::Null | IrType::Void => "undefined".to_string(),
216        IrType::Array(_) => "[]".to_string(),
217        IrType::Object(_) | IrType::Map(_) | IrType::Any => "{}".to_string(),
218        IrType::Ref(name) => format!("{{}} as {}", name),
219        IrType::Binary => "new Blob()".to_string(),
220        IrType::Union(variants) => {
221            if let Some(first) = variants.first() {
222                mock_value_ts(first)
223            } else {
224                "{}".to_string()
225            }
226        }
227    }
228}
229
230/// Mock path parameter value as a string for URL patterns.
231fn mock_path_value_ts(ir_type: &IrType) -> String {
232    match ir_type {
233        IrType::Integer | IrType::Number => "1".to_string(),
234        _ => "test".to_string(),
235    }
236}
237
238/// Guess a mock IrType from a return type string for simple response mocking.
239fn guess_mock_type(return_type: &str) -> IrType {
240    match return_type {
241        "string" => IrType::String,
242        "number" => IrType::Number,
243        "boolean" => IrType::Boolean,
244        "void" => IrType::Void,
245        t if t.ends_with("[]") => IrType::Array(Box::new(IrType::Any)),
246        _ => IrType::Ref(return_type.to_string()),
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn test_mock_value_ts() {
256        assert_eq!(mock_value_ts(&IrType::String), "\"test\"");
257        assert_eq!(mock_value_ts(&IrType::Integer), "1");
258        assert_eq!(mock_value_ts(&IrType::Boolean), "true");
259        assert_eq!(mock_value_ts(&IrType::Void), "undefined");
260        assert_eq!(mock_value_ts(&IrType::Ref("Pet".to_string())), "{} as Pet");
261    }
262
263    #[test]
264    fn test_mock_path_value() {
265        assert_eq!(mock_path_value_ts(&IrType::Integer), "1");
266        assert_eq!(mock_path_value_ts(&IrType::String), "test");
267    }
268}