Skip to main content

alef_e2e/codegen/
java.rs

1//! Java e2e test generator using JUnit 5.
2//!
3//! Generates `e2e/java/pom.xml` and `src/test/java/dev/kreuzberg/e2e/{Category}Test.java`
4//! files from JSON fixtures, driven entirely by `E2eConfig` and `CallConfig`.
5
6use crate::config::E2eConfig;
7use crate::escape::{escape_java, sanitize_filename};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use anyhow::Result;
13use heck::ToUpperCamelCase;
14use std::fmt::Write as FmtWrite;
15use std::path::PathBuf;
16
17use super::E2eCodegen;
18
19/// Java e2e code generator.
20pub struct JavaCodegen;
21
22impl E2eCodegen for JavaCodegen {
23    fn generate(
24        &self,
25        groups: &[FixtureGroup],
26        e2e_config: &E2eConfig,
27        alef_config: &AlefConfig,
28    ) -> Result<Vec<GeneratedFile>> {
29        let lang = self.language_name();
30        let output_base = PathBuf::from(&e2e_config.output).join(lang);
31
32        let mut files = Vec::new();
33
34        // Resolve call config with overrides.
35        let call = &e2e_config.call;
36        let overrides = call.overrides.get(lang);
37        let module_path = overrides
38            .and_then(|o| o.module.as_ref())
39            .cloned()
40            .unwrap_or_else(|| call.module.clone());
41        let function_name = overrides
42            .and_then(|o| o.function.as_ref())
43            .cloned()
44            .unwrap_or_else(|| call.function.clone());
45        let class_name = overrides
46            .and_then(|o| o.class.as_ref())
47            .cloned()
48            .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
49        let result_var = &call.result_var;
50
51        // Resolve package config.
52        let java_pkg = e2e_config.packages.get("java");
53        let pkg_name = java_pkg
54            .and_then(|p| p.name.as_ref())
55            .cloned()
56            .unwrap_or_else(|| alef_config.crate_config.name.clone());
57
58        // Generate pom.xml.
59        files.push(GeneratedFile {
60            path: output_base.join("pom.xml"),
61            content: render_pom_xml(&pkg_name),
62            generated_header: false,
63        });
64
65        // Generate test files per category.
66        let test_base = output_base
67            .join("src")
68            .join("test")
69            .join("java")
70            .join("dev")
71            .join("kreuzberg")
72            .join("e2e");
73
74        // Resolve options_type from override.
75        let options_type = overrides.and_then(|o| o.options_type.clone());
76        let field_resolver = FieldResolver::new(&e2e_config.fields, &e2e_config.fields_optional);
77
78        for group in groups {
79            let active: Vec<&Fixture> = group
80                .fixtures
81                .iter()
82                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
83                .collect();
84
85            if active.is_empty() {
86                continue;
87            }
88
89            let class_file_name = format!("{}Test.java", sanitize_filename(&group.category).to_upper_camel_case());
90            let content = render_test_file(
91                &group.category,
92                &active,
93                &module_path,
94                &class_name,
95                &function_name,
96                result_var,
97                &e2e_config.call.args,
98                options_type.as_deref(),
99                &field_resolver,
100            );
101            files.push(GeneratedFile {
102                path: test_base.join(class_file_name),
103                content,
104                generated_header: true,
105            });
106        }
107
108        Ok(files)
109    }
110
111    fn language_name(&self) -> &'static str {
112        "java"
113    }
114}
115
116// ---------------------------------------------------------------------------
117// Rendering
118// ---------------------------------------------------------------------------
119
120fn render_pom_xml(pkg_name: &str) -> String {
121    let artifact_id = format!("{pkg_name}-e2e-java");
122    format!(
123        r#"<?xml version="1.0" encoding="UTF-8"?>
124<project xmlns="http://maven.apache.org/POM/4.0.0"
125         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
126         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
127    <modelVersion>4.0.0</modelVersion>
128
129    <groupId>dev.kreuzberg</groupId>
130    <artifactId>{artifact_id}</artifactId>
131    <version>0.1.0</version>
132
133    <properties>
134        <maven.compiler.source>21</maven.compiler.source>
135        <maven.compiler.target>21</maven.compiler.target>
136        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
137        <junit.version>5.11.4</junit.version>
138    </properties>
139
140    <dependencies>
141        <dependency>
142            <groupId>org.junit.jupiter</groupId>
143            <artifactId>junit-jupiter</artifactId>
144            <version>${{junit.version}}</version>
145            <scope>test</scope>
146        </dependency>
147    </dependencies>
148
149    <build>
150        <plugins>
151            <plugin>
152                <groupId>org.codehaus.mojo</groupId>
153                <artifactId>build-helper-maven-plugin</artifactId>
154                <version>3.6.0</version>
155                <executions>
156                    <execution>
157                        <id>add-test-source</id>
158                        <phase>generate-test-sources</phase>
159                        <goals>
160                            <goal>add-test-source</goal>
161                        </goals>
162                        <configuration>
163                            <sources>
164                                <source>src/test/java</source>
165                            </sources>
166                        </configuration>
167                    </execution>
168                </executions>
169            </plugin>
170            <plugin>
171                <groupId>org.apache.maven.plugins</groupId>
172                <artifactId>maven-surefire-plugin</artifactId>
173                <version>3.5.2</version>
174                <configuration>
175                    <argLine>--enable-preview --enable-native-access=ALL-UNNAMED -Djava.library.path=../../target/release</argLine>
176                </configuration>
177            </plugin>
178        </plugins>
179    </build>
180</project>
181"#
182    )
183}
184
185#[allow(clippy::too_many_arguments)]
186fn render_test_file(
187    category: &str,
188    fixtures: &[&Fixture],
189    import_class: &str,
190    class_name: &str,
191    function_name: &str,
192    result_var: &str,
193    args: &[crate::config::ArgMapping],
194    options_type: Option<&str>,
195    field_resolver: &FieldResolver,
196) -> String {
197    let mut out = String::new();
198    let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
199
200    let _ = writeln!(out, "package dev.kreuzberg.e2e;");
201    let _ = writeln!(out);
202
203    // Check if any fixture uses a json_object arg with options_type (needs ObjectMapper).
204    let needs_object_mapper = options_type.is_some()
205        && fixtures.iter().any(|f| {
206            args.iter()
207                .any(|arg| arg.arg_type == "json_object" && f.input.get(&arg.field).is_some_and(|v| !v.is_null()))
208        });
209
210    let _ = writeln!(out, "import org.junit.jupiter.api.Test;");
211    let _ = writeln!(out, "import static org.junit.jupiter.api.Assertions.*;");
212    if !import_class.is_empty() {
213        let _ = writeln!(out, "import {import_class};");
214    }
215    if needs_object_mapper {
216        let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper;");
217    }
218    let _ = writeln!(out);
219
220    let _ = writeln!(out, "/** E2e tests for category: {category}. */");
221    let _ = writeln!(out, "class {test_class_name} {{");
222
223    if needs_object_mapper {
224        let _ = writeln!(out);
225        let _ = writeln!(
226            out,
227            "    private static final ObjectMapper MAPPER = new ObjectMapper();"
228        );
229    }
230
231    for fixture in fixtures {
232        render_test_method(
233            &mut out,
234            fixture,
235            class_name,
236            function_name,
237            result_var,
238            args,
239            options_type,
240            field_resolver,
241        );
242        let _ = writeln!(out);
243    }
244
245    let _ = writeln!(out, "}}");
246    out
247}
248
249fn render_test_method(
250    out: &mut String,
251    fixture: &Fixture,
252    class_name: &str,
253    function_name: &str,
254    result_var: &str,
255    args: &[crate::config::ArgMapping],
256    options_type: Option<&str>,
257    field_resolver: &FieldResolver,
258) {
259    let method_name = fixture.id.to_upper_camel_case();
260    let description = &fixture.description;
261    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
262
263    // Check if this test needs ObjectMapper deserialization for json_object args.
264    let needs_deser = options_type.is_some()
265        && args
266            .iter()
267            .any(|arg| arg.arg_type == "json_object" && fixture.input.get(&arg.field).is_some_and(|v| !v.is_null()));
268
269    let throws_clause = if needs_deser { " throws Exception" } else { "" };
270
271    let _ = writeln!(out, "    @Test");
272    let _ = writeln!(out, "    void test{method_name}(){throws_clause} {{");
273    let _ = writeln!(out, "        // {description}");
274
275    // Emit ObjectMapper deserialization bindings for json_object args.
276    if let (true, Some(opts_type)) = (needs_deser, options_type) {
277        for arg in args {
278            if arg.arg_type == "json_object" {
279                if let Some(val) = fixture.input.get(&arg.field) {
280                    if !val.is_null() {
281                        let json_str = serde_json::to_string(val).unwrap_or_default();
282                        let var_name = &arg.name;
283                        let _ = writeln!(
284                            out,
285                            "        var {var_name} = MAPPER.readValue(\"{}\", {opts_type}.class);",
286                            escape_java(&json_str)
287                        );
288                    }
289                }
290            }
291        }
292    }
293
294    let args_str = build_args_string(&fixture.input, args, options_type);
295
296    if expects_error {
297        let _ = writeln!(
298            out,
299            "        assertThrows(Exception.class, () -> {class_name}.{function_name}({args_str}));"
300        );
301        let _ = writeln!(out, "    }}");
302        return;
303    }
304
305    let _ = writeln!(
306        out,
307        "        var {result_var} = {class_name}.{function_name}({args_str});"
308    );
309
310    for assertion in &fixture.assertions {
311        render_assertion(out, assertion, result_var, field_resolver);
312    }
313
314    let _ = writeln!(out, "    }}");
315}
316
317fn build_args_string(
318    input: &serde_json::Value,
319    args: &[crate::config::ArgMapping],
320    options_type: Option<&str>,
321) -> String {
322    if args.is_empty() {
323        return json_to_java(input);
324    }
325
326    let parts: Vec<String> = args
327        .iter()
328        .filter_map(|arg| {
329            let val = input.get(&arg.field)?;
330            if val.is_null() && arg.optional {
331                return None;
332            }
333            // For json_object args with options_type, use the pre-deserialized variable.
334            if arg.arg_type == "json_object" && options_type.is_some() {
335                return Some(arg.name.clone());
336            }
337            Some(json_to_java(val))
338        })
339        .collect();
340
341    parts.join(", ")
342}
343
344fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
345    let field_expr = match &assertion.field {
346        Some(f) if !f.is_empty() => field_resolver.accessor(f, "java", result_var),
347        _ => result_var.to_string(),
348    };
349
350    match assertion.assertion_type.as_str() {
351        "equals" => {
352            if let Some(expected) = &assertion.value {
353                let java_val = json_to_java(expected);
354                let _ = writeln!(out, "        assertEquals({java_val}, {field_expr}.strip());");
355            }
356        }
357        "contains" => {
358            if let Some(expected) = &assertion.value {
359                let java_val = json_to_java(expected);
360                let _ = writeln!(
361                    out,
362                    "        assertTrue({field_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
363                );
364            }
365        }
366        "contains_all" => {
367            if let Some(values) = &assertion.values {
368                for val in values {
369                    let java_val = json_to_java(val);
370                    let _ = writeln!(
371                        out,
372                        "        assertTrue({field_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
373                    );
374                }
375            }
376        }
377        "not_contains" => {
378            if let Some(expected) = &assertion.value {
379                let java_val = json_to_java(expected);
380                let _ = writeln!(
381                    out,
382                    "        assertFalse({field_expr}.contains({java_val}), \"expected NOT to contain: \" + {java_val});"
383                );
384            }
385        }
386        "not_empty" => {
387            let _ = writeln!(
388                out,
389                "        assertFalse({field_expr}.isEmpty(), \"expected non-empty value\");"
390            );
391        }
392        "is_empty" => {
393            let _ = writeln!(
394                out,
395                "        assertTrue({field_expr}.isEmpty(), \"expected empty value\");"
396            );
397        }
398        "starts_with" => {
399            if let Some(expected) = &assertion.value {
400                let java_val = json_to_java(expected);
401                let _ = writeln!(
402                    out,
403                    "        assertTrue({field_expr}.startsWith({java_val}), \"expected to start with: \" + {java_val});"
404                );
405            }
406        }
407        "ends_with" => {
408            if let Some(expected) = &assertion.value {
409                let java_val = json_to_java(expected);
410                let _ = writeln!(
411                    out,
412                    "        assertTrue({field_expr}.endsWith({java_val}), \"expected to end with: \" + {java_val});"
413                );
414            }
415        }
416        "min_length" => {
417            if let Some(val) = &assertion.value {
418                if let Some(n) = val.as_u64() {
419                    let _ = writeln!(
420                        out,
421                        "        assertTrue({field_expr}.length() >= {n}, \"expected length >= {n}\");"
422                    );
423                }
424            }
425        }
426        "max_length" => {
427            if let Some(val) = &assertion.value {
428                if let Some(n) = val.as_u64() {
429                    let _ = writeln!(
430                        out,
431                        "        assertTrue({field_expr}.length() <= {n}, \"expected length <= {n}\");"
432                    );
433                }
434            }
435        }
436        "not_error" => {
437            // Already handled by the call succeeding without exception.
438        }
439        "error" => {
440            // Handled at the test method level.
441        }
442        other => {
443            let _ = writeln!(out, "        // TODO: unsupported assertion type: {other}");
444        }
445    }
446}
447
448/// Convert a `serde_json::Value` to a Java literal string.
449fn json_to_java(value: &serde_json::Value) -> String {
450    match value {
451        serde_json::Value::String(s) => format!("\"{}\"", escape_java(s)),
452        serde_json::Value::Bool(b) => b.to_string(),
453        serde_json::Value::Number(n) => {
454            if n.is_f64() {
455                format!("{}d", n)
456            } else {
457                n.to_string()
458            }
459        }
460        serde_json::Value::Null => "null".to_string(),
461        serde_json::Value::Array(arr) => {
462            let items: Vec<String> = arr.iter().map(json_to_java).collect();
463            format!("java.util.List.of({})", items.join(", "))
464        }
465        serde_json::Value::Object(_) => {
466            let json_str = serde_json::to_string(value).unwrap_or_default();
467            format!("\"{}\"", escape_java(&json_str))
468        }
469    }
470}