1use crate::config::E2eConfig;
7use crate::escape::{escape_kotlin, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::ResolvedCrateConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions::{maven, toolchain};
14use anyhow::Result;
15use heck::{ToLowerCamelCase, ToUpperCamelCase};
16use std::collections::HashSet;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21use super::client;
22
23pub struct KotlinE2eCodegen;
25
26impl E2eCodegen for KotlinE2eCodegen {
27 fn generate(
28 &self,
29 groups: &[FixtureGroup],
30 e2e_config: &E2eConfig,
31 config: &ResolvedCrateConfig,
32 _type_defs: &[alef_core::ir::TypeDef],
33 ) -> Result<Vec<GeneratedFile>> {
34 let lang = self.language_name();
35 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
36
37 let mut files = Vec::new();
38
39 let call = &e2e_config.call;
41 let overrides = call.overrides.get(lang);
42 let _module_path = overrides
43 .and_then(|o| o.module.as_ref())
44 .cloned()
45 .unwrap_or_else(|| call.module.clone());
46 let function_name = overrides
47 .and_then(|o| o.function.as_ref())
48 .cloned()
49 .unwrap_or_else(|| call.function.clone());
50 let class_name = overrides
51 .and_then(|o| o.class.as_ref())
52 .cloned()
53 .unwrap_or_else(|| config.name.to_upper_camel_case());
54 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
55 let result_var = &call.result_var;
56
57 let kotlin_pkg = e2e_config.resolve_package("kotlin");
59 let pkg_name = kotlin_pkg
60 .as_ref()
61 .and_then(|p| p.name.as_ref())
62 .cloned()
63 .unwrap_or_else(|| config.name.clone());
64
65 let _kotlin_pkg_path = kotlin_pkg
67 .as_ref()
68 .and_then(|p| p.path.as_ref())
69 .cloned()
70 .unwrap_or_else(|| "../../packages/kotlin".to_string());
71 let kotlin_version = kotlin_pkg
72 .as_ref()
73 .and_then(|p| p.version.as_ref())
74 .cloned()
75 .or_else(|| config.resolved_version())
76 .unwrap_or_else(|| "0.1.0".to_string());
77 let kotlin_pkg_id = config.kotlin_package();
78
79 let needs_mock_server = groups
85 .iter()
86 .flat_map(|g| g.fixtures.iter())
87 .any(|f| f.needs_mock_server());
88
89 files.push(GeneratedFile {
91 path: output_base.join("build.gradle.kts"),
92 content: render_build_gradle(
93 &pkg_name,
94 &kotlin_pkg_id,
95 &kotlin_version,
96 e2e_config.dep_mode,
97 needs_mock_server,
98 ),
99 generated_header: false,
100 });
101
102 let mut test_base = output_base.join("src").join("test").join("kotlin");
106 for segment in kotlin_pkg_id.split('.') {
107 test_base = test_base.join(segment);
108 }
109 let test_base = test_base.join("e2e");
110
111 if needs_mock_server {
112 files.push(GeneratedFile {
113 path: test_base.join("MockServerListener.kt"),
114 content: render_mock_server_listener_kt(&kotlin_pkg_id),
115 generated_header: true,
116 });
117 files.push(GeneratedFile {
118 path: output_base
119 .join("src")
120 .join("test")
121 .join("resources")
122 .join("META-INF")
123 .join("services")
124 .join("org.junit.platform.launcher.LauncherSessionListener"),
125 content: format!("{kotlin_pkg_id}.e2e.MockServerListener\n"),
126 generated_header: false,
127 });
128 }
129
130 let options_type = overrides.and_then(|o| o.options_type.clone());
132 let field_resolver = FieldResolver::new(
133 &e2e_config.fields,
134 &e2e_config.fields_optional,
135 &e2e_config.result_fields,
136 &e2e_config.fields_array,
137 &HashSet::new(),
138 );
139
140 for group in groups {
141 let active: Vec<&Fixture> = group
142 .fixtures
143 .iter()
144 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
145 .collect();
146
147 if active.is_empty() {
148 continue;
149 }
150
151 let class_file_name = format!("{}Test.kt", sanitize_filename(&group.category).to_upper_camel_case());
152 let content = render_test_file(
153 &group.category,
154 &active,
155 &class_name,
156 &function_name,
157 &kotlin_pkg_id,
158 result_var,
159 &e2e_config.call.args,
160 options_type.as_deref(),
161 &field_resolver,
162 result_is_simple,
163 &e2e_config.fields_enum,
164 e2e_config,
165 );
166 files.push(GeneratedFile {
167 path: test_base.join(class_file_name),
168 content,
169 generated_header: true,
170 });
171 }
172
173 Ok(files)
174 }
175
176 fn language_name(&self) -> &'static str {
177 "kotlin"
178 }
179}
180
181fn render_build_gradle(
186 pkg_name: &str,
187 kotlin_pkg_id: &str,
188 pkg_version: &str,
189 dep_mode: crate::config::DependencyMode,
190 needs_mock_server: bool,
191) -> String {
192 let dep_block = match dep_mode {
193 crate::config::DependencyMode::Registry => {
194 format!(r#" testImplementation("{kotlin_pkg_id}:{pkg_name}:{pkg_version}")"#)
196 }
197 crate::config::DependencyMode::Local => {
198 let jar_name = pkg_name.rsplit(':').next().unwrap_or(pkg_name);
203 format!(r#" testImplementation(files("../../target/release/{jar_name}.jar"))"#)
204 }
205 };
206
207 let kotlin_plugin = maven::KOTLIN_JVM_PLUGIN;
208 let junit = maven::JUNIT;
209 let jackson = maven::JACKSON_E2E;
210 let jvm_target = toolchain::JVM_TARGET;
211 let launcher_dep = if needs_mock_server {
212 format!(r#" testImplementation("org.junit.platform:junit-platform-launcher:{junit}")"#)
213 } else {
214 String::new()
215 };
216 format!(
217 r#"import org.jetbrains.kotlin.gradle.dsl.JvmTarget
218
219plugins {{
220 kotlin("jvm") version "{kotlin_plugin}"
221}}
222
223group = "{kotlin_pkg_id}"
224version = "0.1.0"
225
226java {{
227 sourceCompatibility = JavaVersion.VERSION_{jvm_target}
228 targetCompatibility = JavaVersion.VERSION_{jvm_target}
229}}
230
231kotlin {{
232 compilerOptions {{
233 jvmTarget.set(JvmTarget.JVM_{jvm_target})
234 }}
235}}
236
237repositories {{
238 mavenCentral()
239}}
240
241dependencies {{
242{dep_block}
243 testImplementation("org.junit.jupiter:junit-jupiter-api:{junit}")
244 testImplementation("org.junit.jupiter:junit-jupiter-engine:{junit}")
245{launcher_dep}
246 testImplementation("com.fasterxml.jackson.core:jackson-databind:{jackson}")
247 testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:{jackson}")
248 testImplementation(kotlin("test"))
249}}
250
251tasks.test {{
252 useJUnitPlatform()
253 val libPath = System.getProperty("kb.lib.path") ?: "${{rootDir}}/../../target/release"
254 systemProperty("java.library.path", libPath)
255 systemProperty("jna.library.path", libPath)
256}}
257"#
258 )
259}
260
261fn render_mock_server_listener_kt(kotlin_pkg_id: &str) -> String {
270 let header = hash::header(CommentStyle::DoubleSlash);
271 format!(
272 r#"{header}package {kotlin_pkg_id}.e2e
273
274import java.io.BufferedReader
275import java.io.IOException
276import java.io.InputStreamReader
277import java.nio.charset.StandardCharsets
278import java.nio.file.Path
279import java.nio.file.Paths
280import java.util.regex.Pattern
281import org.junit.platform.launcher.LauncherSession
282import org.junit.platform.launcher.LauncherSessionListener
283
284/**
285 * Spawns the mock-server binary once per JUnit launcher session and
286 * exposes its URL as the `mockServerUrl` system property. Generated
287 * test bodies read the property (with `MOCK_SERVER_URL` env-var
288 * fallback) so tests can run via plain `./gradlew test` without any
289 * external mock-server orchestration. Mirrors the Ruby spec_helper /
290 * Python conftest spawn pattern. Honors a pre-set MOCK_SERVER_URL by
291 * skipping the spawn entirely.
292 */
293class MockServerListener : LauncherSessionListener {{
294 private var mockServer: Process? = null
295
296 override fun launcherSessionOpened(session: LauncherSession) {{
297 val preset = System.getenv("MOCK_SERVER_URL")
298 if (!preset.isNullOrEmpty()) {{
299 System.setProperty("mockServerUrl", preset)
300 return
301 }}
302 val repoRoot = locateRepoRoot()
303 ?: error("MockServerListener: could not locate repo root (looked for fixtures/ in ancestors of ${{System.getProperty("user.dir")}})")
304 val binName = if (System.getProperty("os.name", "").lowercase().contains("win")) "mock-server.exe" else "mock-server"
305 val bin = repoRoot.resolve("e2e").resolve("rust").resolve("target").resolve("release").resolve(binName).toFile()
306 val fixturesDir = repoRoot.resolve("fixtures").toFile()
307 check(bin.exists()) {{
308 "MockServerListener: mock-server binary not found at $bin — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release"
309 }}
310 val pb = ProcessBuilder(bin.absolutePath, fixturesDir.absolutePath)
311 .redirectErrorStream(false)
312 val server = try {{
313 pb.start()
314 }} catch (e: IOException) {{
315 throw IllegalStateException("MockServerListener: failed to start mock-server", e)
316 }}
317 mockServer = server
318 // Read until we see MOCK_SERVER_URL= and optionally MOCK_SERVERS=.
319 // Cap the loop so a misbehaving mock-server cannot block indefinitely.
320 val stdout = BufferedReader(InputStreamReader(server.inputStream, StandardCharsets.UTF_8))
321 var url: String? = null
322 try {{
323 for (i in 0 until 16) {{
324 val line = stdout.readLine() ?: break
325 when {{
326 line.startsWith("MOCK_SERVER_URL=") -> {{
327 url = line.removePrefix("MOCK_SERVER_URL=").trim()
328 }}
329 line.startsWith("MOCK_SERVERS=") -> {{
330 val jsonVal = line.removePrefix("MOCK_SERVERS=").trim()
331 System.setProperty("mockServers", jsonVal)
332 // Parse JSON map of fixture_id -> url and expose as system properties.
333 val p = Pattern.compile(""""([^"]+)":"([^"]+)"""")
334 val matcher = p.matcher(jsonVal)
335 while (matcher.find()) {{
336 System.setProperty("mockServer.${{matcher.group(1)}}", matcher.group(2))
337 }}
338 break
339 }}
340 url != null -> break
341 }}
342 }}
343 }} catch (e: IOException) {{
344 server.destroyForcibly()
345 throw IllegalStateException("MockServerListener: failed to read mock-server stdout", e)
346 }}
347 if (url.isNullOrEmpty()) {{
348 server.destroyForcibly()
349 error("MockServerListener: mock-server did not emit MOCK_SERVER_URL")
350 }}
351 // TCP-readiness probe: ensure axum::serve is accepting before tests start.
352 // The mock-server binds the TcpListener synchronously then prints the URL
353 // before tokio::spawn(axum::serve(...)) is polled, so under Gradle parallel
354 // mode tests can race startup. Poll-connect (max 5s, 50ms backoff) until success.
355 val healthUri = java.net.URI.create(url)
356 val host = healthUri.host
357 val port = healthUri.port
358 val deadline = System.nanoTime() + 5_000_000_000L
359 while (System.nanoTime() < deadline) {{
360 try {{
361 java.net.Socket().use {{ s ->
362 s.connect(java.net.InetSocketAddress(host, port), 100)
363 break
364 }}
365 }} catch (_: java.io.IOException) {{
366 try {{ Thread.sleep(50) }} catch (ie: InterruptedException) {{ Thread.currentThread().interrupt(); break }}
367 }}
368 }}
369 System.setProperty("mockServerUrl", url)
370 // Drain remaining stdout/stderr in daemon threads so a full pipe
371 // does not block the child.
372 Thread {{ drain(stdout) }}.also {{ it.isDaemon = true }}.start()
373 Thread {{ drain(BufferedReader(InputStreamReader(server.errorStream, StandardCharsets.UTF_8))) }}.also {{ it.isDaemon = true }}.start()
374 }}
375
376 override fun launcherSessionClosed(session: LauncherSession) {{
377 val server = mockServer ?: return
378 try {{ server.outputStream.close() }} catch (_: IOException) {{}}
379 try {{
380 if (!server.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)) {{
381 server.destroyForcibly()
382 }}
383 }} catch (ie: InterruptedException) {{
384 Thread.currentThread().interrupt()
385 server.destroyForcibly()
386 }}
387 }}
388
389 companion object {{
390 private fun locateRepoRoot(): Path? {{
391 var dir: Path? = Paths.get("").toAbsolutePath()
392 while (dir != null) {{
393 if (dir.resolve("fixtures").toFile().isDirectory
394 && dir.resolve("e2e").toFile().isDirectory) {{
395 return dir
396 }}
397 dir = dir.parent
398 }}
399 return null
400 }}
401
402 private fun drain(reader: BufferedReader) {{
403 try {{
404 val buf = CharArray(1024)
405 while (reader.read(buf) >= 0) {{ /* drain */ }}
406 }} catch (_: IOException) {{}}
407 }}
408 }}
409}}
410"#
411 )
412}
413
414#[allow(clippy::too_many_arguments)]
415fn render_test_file(
416 category: &str,
417 fixtures: &[&Fixture],
418 class_name: &str,
419 function_name: &str,
420 kotlin_pkg_id: &str,
421 result_var: &str,
422 args: &[crate::config::ArgMapping],
423 options_type: Option<&str>,
424 field_resolver: &FieldResolver,
425 result_is_simple: bool,
426 enum_fields: &HashSet<String>,
427 e2e_config: &E2eConfig,
428) -> String {
429 let mut out = String::new();
430 out.push_str(&hash::header(CommentStyle::DoubleSlash));
431 let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
432
433 let (import_path, simple_class) = if class_name.contains('.') {
436 let simple = class_name.rsplit('.').next().unwrap_or(class_name);
437 (class_name, simple)
438 } else {
439 ("", class_name)
440 };
441
442 let _ = writeln!(out, "package {kotlin_pkg_id}.e2e");
443 let _ = writeln!(out);
444
445 let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
447
448 let needs_object_mapper_for_options = options_type.is_some()
450 && fixtures.iter().any(|f| {
451 args.iter().any(|arg| {
452 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
453 arg.arg_type == "json_object" && f.input.get(field).is_some_and(|v| !v.is_null())
454 })
455 });
456 let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
458 args.iter().filter(|a| a.arg_type == "handle").any(|a| {
459 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
460 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
461 })
462 });
463 let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle || has_http_fixtures;
465
466 let _ = writeln!(out, "import org.junit.jupiter.api.Test");
467 let _ = writeln!(out, "import kotlin.test.assertEquals");
468 let _ = writeln!(out, "import kotlin.test.assertTrue");
469 let _ = writeln!(out, "import kotlin.test.assertFalse");
470 let _ = writeln!(out, "import kotlin.test.assertFailsWith");
471 let has_call_fixtures = fixtures.iter().any(|f| !f.is_http_test());
473 if has_call_fixtures && !import_path.is_empty() {
474 let _ = writeln!(out, "import {import_path}");
475 }
476 if needs_object_mapper {
477 let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper");
478 let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module");
479 }
480 if let Some(opts_type) = options_type {
482 if needs_object_mapper && has_call_fixtures {
483 let opts_package = if !import_path.is_empty() {
485 let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
486 format!("{pkg}.{opts_type}")
487 } else {
488 opts_type.to_string()
489 };
490 let _ = writeln!(out, "import {opts_package}");
491 }
492 }
493 if needs_object_mapper_for_handle && !import_path.is_empty() {
495 let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
496 let _ = writeln!(out, "import {pkg}.CrawlConfig");
497 }
498 let _ = writeln!(out);
499
500 let _ = writeln!(out, "/** E2e tests for category: {category}. */");
501 let _ = writeln!(out, "class {test_class_name} {{");
502
503 if needs_object_mapper {
504 let _ = writeln!(out);
505 let _ = writeln!(out, " companion object {{");
506 let _ = writeln!(
507 out,
508 " private val MAPPER = ObjectMapper().registerModule(Jdk8Module())"
509 );
510 let _ = writeln!(out, " }}");
511 }
512
513 for fixture in fixtures {
514 render_test_method(
515 &mut out,
516 fixture,
517 simple_class,
518 function_name,
519 result_var,
520 args,
521 options_type,
522 field_resolver,
523 result_is_simple,
524 enum_fields,
525 e2e_config,
526 );
527 let _ = writeln!(out);
528 }
529
530 let _ = writeln!(out, "}}");
531 out
532}
533
534struct KotlinTestClientRenderer;
541
542impl client::TestClientRenderer for KotlinTestClientRenderer {
543 fn language_name(&self) -> &'static str {
544 "kotlin"
545 }
546
547 fn sanitize_test_name(&self, id: &str) -> String {
548 sanitize_ident(id).to_upper_camel_case()
549 }
550
551 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
552 let _ = writeln!(out, " @Test");
553 let _ = writeln!(out, " fun test{fn_name}() {{");
554 let _ = writeln!(out, " // {description}");
555 if let Some(reason) = skip_reason {
556 let escaped = escape_kotlin(reason);
557 let _ = writeln!(
558 out,
559 " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"{escaped}\")"
560 );
561 }
562 }
563
564 fn render_test_close(&self, out: &mut String) {
565 let _ = writeln!(out, " }}");
566 }
567
568 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
569 let method = ctx.method.to_uppercase();
570 let fixture_path = ctx.path;
571
572 const JAVA_RESTRICTED_HEADERS: &[&str] = &["connection", "content-length", "expect", "host", "upgrade"];
574
575 let _ = writeln!(
576 out,
577 " val baseUrl = System.getenv(\"MOCK_SERVER_URL\") ?: \"http://localhost:8080\""
578 );
579 let _ = writeln!(out, " val uri = java.net.URI.create(\"$baseUrl{fixture_path}\")");
580
581 let body_publisher = if let Some(body) = ctx.body {
582 let json = serde_json::to_string(body).unwrap_or_default();
583 let escaped = escape_kotlin(&json);
584 format!("java.net.http.HttpRequest.BodyPublishers.ofString(\"{escaped}\")")
585 } else {
586 "java.net.http.HttpRequest.BodyPublishers.noBody()".to_string()
587 };
588
589 let _ = writeln!(out, " val builder = java.net.http.HttpRequest.newBuilder(uri)");
590 let _ = writeln!(out, " .method(\"{method}\", {body_publisher})");
591
592 if ctx.body.is_some() {
594 let content_type = ctx.content_type.unwrap_or("application/json");
595 let _ = writeln!(out, " .header(\"Content-Type\", \"{content_type}\")");
596 }
597
598 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
600 header_pairs.sort_by_key(|(k, _)| k.as_str());
601 for (name, value) in &header_pairs {
602 if JAVA_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
603 continue;
604 }
605 let escaped_name = escape_kotlin(name);
606 let escaped_value = escape_kotlin(value);
607 let _ = writeln!(out, " .header(\"{escaped_name}\", \"{escaped_value}\")");
608 }
609
610 if !ctx.cookies.is_empty() {
612 let mut cookie_pairs: Vec<(&String, &String)> = ctx.cookies.iter().collect();
613 cookie_pairs.sort_by_key(|(k, _)| k.as_str());
614 let cookie_str: Vec<String> = cookie_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect();
615 let cookie_header = escape_kotlin(&cookie_str.join("; "));
616 let _ = writeln!(out, " .header(\"Cookie\", \"{cookie_header}\")");
617 }
618
619 let _ = writeln!(
620 out,
621 " val {} = java.net.http.HttpClient.newHttpClient()",
622 ctx.response_var
623 );
624 let _ = writeln!(
625 out,
626 " .send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString())"
627 );
628 }
629
630 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
631 let _ = writeln!(
632 out,
633 " assertEquals({status}, {response_var}.statusCode(), \"status code mismatch\")"
634 );
635 }
636
637 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
638 let escaped_name = escape_kotlin(name);
639 match expected {
640 "<<present>>" => {
641 let _ = writeln!(
642 out,
643 " assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be present\")"
644 );
645 }
646 "<<absent>>" => {
647 let _ = writeln!(
648 out,
649 " assertFalse({response_var}.headers().firstValue(\"{escaped_name}\").isPresent, \"header {escaped_name} should be absent\")"
650 );
651 }
652 "<<uuid>>" => {
653 let _ = writeln!(
654 out,
655 " assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").matches(\"[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}\"), \"header {escaped_name} should be a UUID\")"
656 );
657 }
658 exact => {
659 let escaped_value = escape_kotlin(exact);
660 let _ = writeln!(
661 out,
662 " assertTrue({response_var}.headers().firstValue(\"{escaped_name}\").orElse(\"\").contains(\"{escaped_value}\"), \"header {escaped_name} mismatch\")"
663 );
664 }
665 }
666 }
667
668 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
669 match expected {
670 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
671 let json_str = serde_json::to_string(expected).unwrap_or_default();
672 let escaped = escape_kotlin(&json_str);
673 let _ = writeln!(out, " val bodyJson = MAPPER.readTree({response_var}.body())");
674 let _ = writeln!(out, " val expectedJson = MAPPER.readTree(\"{escaped}\")");
675 let _ = writeln!(out, " assertEquals(expectedJson, bodyJson, \"body mismatch\")");
676 }
677 serde_json::Value::String(s) => {
678 let escaped = escape_kotlin(s);
679 let _ = writeln!(
680 out,
681 " assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
682 );
683 }
684 other => {
685 let escaped = escape_kotlin(&other.to_string());
686 let _ = writeln!(
687 out,
688 " assertEquals(\"{escaped}\", {response_var}.body().trim(), \"body mismatch\")"
689 );
690 }
691 }
692 }
693
694 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
695 if let Some(obj) = expected.as_object() {
696 let _ = writeln!(out, " val _partialTree = MAPPER.readTree({response_var}.body())");
697 for (key, val) in obj {
698 let escaped_key = escape_kotlin(key);
699 match val {
700 serde_json::Value::String(s) => {
701 let escaped_val = escape_kotlin(s);
702 let _ = writeln!(
703 out,
704 " assertEquals(\"{escaped_val}\", _partialTree.path(\"{escaped_key}\").asText(), \"partial body field '{escaped_key}' mismatch\")"
705 );
706 }
707 serde_json::Value::Bool(b) => {
708 let _ = writeln!(
709 out,
710 " assertEquals({b}, _partialTree.path(\"{escaped_key}\").asBoolean(), \"partial body field '{escaped_key}' mismatch\")"
711 );
712 }
713 serde_json::Value::Number(n) => {
714 let _ = writeln!(
715 out,
716 " assertEquals({n}, _partialTree.path(\"{escaped_key}\").numberValue(), \"partial body field '{escaped_key}' mismatch\")"
717 );
718 }
719 other => {
720 let json_str = serde_json::to_string(other).unwrap_or_default();
721 let escaped_val = escape_kotlin(&json_str);
722 let _ = writeln!(
723 out,
724 " assertEquals(MAPPER.readTree(\"{escaped_val}\"), _partialTree.path(\"{escaped_key}\"), \"partial body field '{escaped_key}' mismatch\")"
725 );
726 }
727 }
728 }
729 }
730 }
731
732 fn render_assert_validation_errors(
733 &self,
734 out: &mut String,
735 response_var: &str,
736 errors: &[ValidationErrorExpectation],
737 ) {
738 let _ = writeln!(out, " val _veTree = MAPPER.readTree({response_var}.body())");
739 let _ = writeln!(out, " val _veErrors = _veTree.path(\"errors\")");
740 for ve in errors {
741 let escaped_msg = escape_kotlin(&ve.msg);
742 let _ = writeln!(
743 out,
744 " assertTrue((0 until _veErrors.size()).any {{ _veErrors.get(it).path(\"msg\").asText().contains(\"{escaped_msg}\") }}, \"expected validation error containing: {escaped_msg}\")"
745 );
746 }
747 }
748}
749
750fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
755 if http.expected_response.status_code == 101 {
757 let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
758 let description = &fixture.description;
759 let _ = writeln!(out, " @Test");
760 let _ = writeln!(out, " fun test{method_name}() {{");
761 let _ = writeln!(out, " // {description}");
762 let _ = writeln!(
763 out,
764 " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"Skipped: Java HttpClient cannot handle 101 Switching Protocols responses\")"
765 );
766 let _ = writeln!(out, " }}");
767 return;
768 }
769
770 client::http_call::render_http_test(out, &KotlinTestClientRenderer, fixture);
771}
772
773#[allow(clippy::too_many_arguments)]
774fn render_test_method(
775 out: &mut String,
776 fixture: &Fixture,
777 class_name: &str,
778 _function_name: &str,
779 _result_var: &str,
780 _args: &[crate::config::ArgMapping],
781 options_type: Option<&str>,
782 field_resolver: &FieldResolver,
783 result_is_simple: bool,
784 enum_fields: &HashSet<String>,
785 e2e_config: &E2eConfig,
786) {
787 if let Some(http) = &fixture.http {
789 render_http_test_method(out, fixture, http);
790 return;
791 }
792
793 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
795 let lang = "kotlin";
796 let call_overrides = call_config.overrides.get(lang);
797
798 if call_overrides.is_none() {
802 let method_name = fixture.id.to_upper_camel_case();
803 let description = &fixture.description;
804 let _ = writeln!(out, " @Test");
805 let _ = writeln!(out, " fun test{method_name}() {{");
806 let _ = writeln!(out, " // {description}");
807 let _ = writeln!(
808 out,
809 " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"TODO: implement Kotlin e2e test for fixture '{}'\")",
810 fixture.id
811 );
812 let _ = writeln!(out, " }}");
813 return;
814 }
815 let effective_function_name = call_overrides
816 .and_then(|o| o.function.as_ref())
817 .cloned()
818 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
819 let effective_result_var = &call_config.result_var;
820 let effective_args = &call_config.args;
821 let function_name = effective_function_name.as_str();
822 let result_var = effective_result_var.as_str();
823 let args: &[crate::config::ArgMapping] = effective_args.as_slice();
824
825 let method_name = fixture.id.to_upper_camel_case();
826 let description = &fixture.description;
827 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
828
829 let needs_deser = options_type.is_some()
831 && args
832 .iter()
833 .any(|arg| arg.arg_type == "json_object" && fixture.input.get(&arg.field).is_some_and(|v| !v.is_null()));
834
835 let _ = writeln!(out, " @Test");
836 let _ = writeln!(out, " fun test{method_name}() {{");
837 let _ = writeln!(out, " // {description}");
838
839 if let (true, Some(opts_type)) = (needs_deser, options_type) {
841 for arg in args {
842 if arg.arg_type == "json_object" {
843 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
844 if let Some(val) = fixture.input.get(field) {
845 if !val.is_null() {
846 let normalized = super::normalize_json_keys_to_snake_case(val);
847 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
848 let var_name = &arg.name;
849 let _ = writeln!(
850 out,
851 " val {var_name} = MAPPER.readValue(\"{}\", {opts_type}::class.java)",
852 escape_kotlin(&json_str)
853 );
854 }
855 }
856 }
857 }
858 }
859
860 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, class_name, options_type, &fixture.id);
861
862 if expects_error {
863 let _ = writeln!(out, " assertFailsWith<Exception> {{");
866 for line in &setup_lines {
867 let _ = writeln!(out, " {line}");
868 }
869 let _ = writeln!(out, " {class_name}.{function_name}({args_str})");
870 let _ = writeln!(out, " }}");
871 let _ = writeln!(out, " }}");
872 return;
873 }
874
875 for line in &setup_lines {
876 let _ = writeln!(out, " {line}");
877 }
878
879 let _ = writeln!(
880 out,
881 " val {result_var} = {class_name}.{function_name}({args_str})"
882 );
883
884 for assertion in &fixture.assertions {
885 render_assertion(
886 out,
887 assertion,
888 result_var,
889 class_name,
890 field_resolver,
891 result_is_simple,
892 enum_fields,
893 );
894 }
895
896 let _ = writeln!(out, " }}");
897}
898
899fn build_args_and_setup(
903 input: &serde_json::Value,
904 args: &[crate::config::ArgMapping],
905 class_name: &str,
906 options_type: Option<&str>,
907 fixture_id: &str,
908) -> (Vec<String>, String) {
909 if args.is_empty() {
910 return (Vec::new(), String::new());
911 }
912
913 let mut setup_lines: Vec<String> = Vec::new();
914 let mut parts: Vec<String> = Vec::new();
915
916 for arg in args {
917 if arg.arg_type == "mock_url" {
918 setup_lines.push(format!(
919 "val {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"",
920 arg.name,
921 ));
922 parts.push(arg.name.clone());
923 continue;
924 }
925
926 if arg.arg_type == "handle" {
927 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
928 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
929 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
930 if config_value.is_null()
931 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
932 {
933 setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
934 } else {
935 let json_str = serde_json::to_string(config_value).unwrap_or_default();
936 let name = &arg.name;
937 setup_lines.push(format!(
938 "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
939 escape_kotlin(&json_str),
940 ));
941 setup_lines.push(format!(
942 "val {} = {class_name}.{constructor_name}({name}Config)",
943 arg.name,
944 name = name,
945 ));
946 }
947 parts.push(arg.name.clone());
948 continue;
949 }
950
951 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
952 let val = input.get(field);
953 match val {
954 None | Some(serde_json::Value::Null) if arg.optional => {
955 continue;
956 }
957 None | Some(serde_json::Value::Null) => {
958 let default_val = match arg.arg_type.as_str() {
959 "string" => "\"\"".to_string(),
960 "int" | "integer" => "0".to_string(),
961 "float" | "number" => "0.0".to_string(),
962 "bool" | "boolean" => "false".to_string(),
963 _ => "null".to_string(),
964 };
965 parts.push(default_val);
966 }
967 Some(v) => {
968 if arg.arg_type == "json_object" && options_type.is_some() {
970 parts.push(arg.name.clone());
971 continue;
972 }
973 if arg.arg_type == "bytes" {
975 let val = json_to_kotlin(v);
976 parts.push(format!("{val}.toByteArray()"));
977 continue;
978 }
979 parts.push(json_to_kotlin(v));
980 }
981 }
982 }
983
984 (setup_lines, parts.join(", "))
985}
986
987fn render_assertion(
988 out: &mut String,
989 assertion: &Assertion,
990 result_var: &str,
991 _class_name: &str,
992 field_resolver: &FieldResolver,
993 result_is_simple: bool,
994 enum_fields: &HashSet<String>,
995) {
996 if let Some(f) = &assertion.field {
998 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
999 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
1000 return;
1001 }
1002 }
1003
1004 let field_is_enum = assertion
1006 .field
1007 .as_deref()
1008 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1009
1010 let field_expr = if result_is_simple {
1012 result_var.to_string()
1013 } else {
1014 match &assertion.field {
1015 Some(f) if !f.is_empty() => field_resolver.accessor(f, "kotlin", result_var),
1016 _ => result_var.to_string(),
1017 }
1018 };
1019
1020 let field_is_optional = !result_is_simple
1024 && assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1025 let resolved = field_resolver.resolve(f);
1026 if field_resolver.has_map_access(f) {
1027 return false;
1028 }
1029 if field_resolver.is_optional(resolved) {
1031 return true;
1032 }
1033 let mut prefix = String::new();
1036 for part in resolved.split('.') {
1037 let key = part.split('[').next().unwrap_or(part);
1039 if !prefix.is_empty() {
1040 prefix.push('.');
1041 }
1042 prefix.push_str(key);
1043 if field_resolver.is_optional(&prefix) {
1044 return true;
1045 }
1046 }
1047 false
1048 });
1049
1050 let string_field_expr = if field_is_optional {
1053 format!("{field_expr}.orEmpty()")
1054 } else {
1055 field_expr.clone()
1056 };
1057
1058 let nonnull_field_expr = if field_is_optional {
1061 format!("{field_expr}!!")
1062 } else {
1063 field_expr.clone()
1064 };
1065
1066 let string_expr = if field_is_enum {
1068 format!("{string_field_expr}.getValue()")
1069 } else {
1070 string_field_expr.clone()
1071 };
1072
1073 match assertion.assertion_type.as_str() {
1074 "equals" => {
1075 if let Some(expected) = &assertion.value {
1076 let kotlin_val = json_to_kotlin(expected);
1077 if expected.is_string() {
1078 let _ = writeln!(out, " assertEquals({kotlin_val}, {string_expr}.trim())");
1079 } else {
1080 let _ = writeln!(out, " assertEquals({kotlin_val}, {nonnull_field_expr})");
1081 }
1082 }
1083 }
1084 "contains" => {
1085 if let Some(expected) = &assertion.value {
1086 let kotlin_val = json_to_kotlin(expected);
1087 let _ = writeln!(
1088 out,
1089 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1090 );
1091 }
1092 }
1093 "contains_all" => {
1094 if let Some(values) = &assertion.values {
1095 for val in values {
1096 let kotlin_val = json_to_kotlin(val);
1097 let _ = writeln!(
1098 out,
1099 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1100 );
1101 }
1102 }
1103 }
1104 "not_contains" => {
1105 if let Some(expected) = &assertion.value {
1106 let kotlin_val = json_to_kotlin(expected);
1107 let _ = writeln!(
1108 out,
1109 " assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
1110 );
1111 }
1112 }
1113 "not_empty" => {
1114 let _ = writeln!(
1115 out,
1116 " assertFalse({string_field_expr}.isEmpty(), \"expected non-empty value\")"
1117 );
1118 }
1119 "is_empty" => {
1120 let _ = writeln!(
1121 out,
1122 " assertTrue({string_field_expr}.isEmpty(), \"expected empty value\")"
1123 );
1124 }
1125 "contains_any" => {
1126 if let Some(values) = &assertion.values {
1127 let checks: Vec<String> = values
1128 .iter()
1129 .map(|v| {
1130 let kotlin_val = json_to_kotlin(v);
1131 format!("{string_expr}.contains({kotlin_val})")
1132 })
1133 .collect();
1134 let joined = checks.join(" || ");
1135 let _ = writeln!(
1136 out,
1137 " assertTrue({joined}, \"expected to contain at least one of the specified values\")"
1138 );
1139 }
1140 }
1141 "greater_than" => {
1142 if let Some(val) = &assertion.value {
1143 let kotlin_val = json_to_kotlin(val);
1144 let _ = writeln!(
1145 out,
1146 " assertTrue({nonnull_field_expr} > {kotlin_val}, \"expected > {{kotlin_val}}\")"
1147 );
1148 }
1149 }
1150 "less_than" => {
1151 if let Some(val) = &assertion.value {
1152 let kotlin_val = json_to_kotlin(val);
1153 let _ = writeln!(
1154 out,
1155 " assertTrue({nonnull_field_expr} < {kotlin_val}, \"expected < {{kotlin_val}}\")"
1156 );
1157 }
1158 }
1159 "greater_than_or_equal" => {
1160 if let Some(val) = &assertion.value {
1161 let kotlin_val = json_to_kotlin(val);
1162 let _ = writeln!(
1163 out,
1164 " assertTrue({nonnull_field_expr} >= {kotlin_val}, \"expected >= {{kotlin_val}}\")"
1165 );
1166 }
1167 }
1168 "less_than_or_equal" => {
1169 if let Some(val) = &assertion.value {
1170 let kotlin_val = json_to_kotlin(val);
1171 let _ = writeln!(
1172 out,
1173 " assertTrue({nonnull_field_expr} <= {kotlin_val}, \"expected <= {{kotlin_val}}\")"
1174 );
1175 }
1176 }
1177 "starts_with" => {
1178 if let Some(expected) = &assertion.value {
1179 let kotlin_val = json_to_kotlin(expected);
1180 let _ = writeln!(
1181 out,
1182 " assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
1183 );
1184 }
1185 }
1186 "ends_with" => {
1187 if let Some(expected) = &assertion.value {
1188 let kotlin_val = json_to_kotlin(expected);
1189 let _ = writeln!(
1190 out,
1191 " assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
1192 );
1193 }
1194 }
1195 "min_length" => {
1196 if let Some(val) = &assertion.value {
1197 if let Some(n) = val.as_u64() {
1198 let _ = writeln!(
1199 out,
1200 " assertTrue({string_field_expr}.length >= {n}, \"expected length >= {n}\")"
1201 );
1202 }
1203 }
1204 }
1205 "max_length" => {
1206 if let Some(val) = &assertion.value {
1207 if let Some(n) = val.as_u64() {
1208 let _ = writeln!(
1209 out,
1210 " assertTrue({string_field_expr}.length <= {n}, \"expected length <= {n}\")"
1211 );
1212 }
1213 }
1214 }
1215 "count_min" => {
1216 if let Some(val) = &assertion.value {
1217 if let Some(n) = val.as_u64() {
1218 let _ = writeln!(
1219 out,
1220 " assertTrue({nonnull_field_expr}.size >= {n}, \"expected at least {n} elements\")"
1221 );
1222 }
1223 }
1224 }
1225 "count_equals" => {
1226 if let Some(val) = &assertion.value {
1227 if let Some(n) = val.as_u64() {
1228 let _ = writeln!(
1229 out,
1230 " assertEquals({n}, {nonnull_field_expr}.size, \"expected exactly {n} elements\")"
1231 );
1232 }
1233 }
1234 }
1235 "is_true" => {
1236 let _ = writeln!(out, " assertTrue({field_expr}, \"expected true\")");
1237 }
1238 "is_false" => {
1239 let _ = writeln!(out, " assertFalse({field_expr}, \"expected false\")");
1240 }
1241 "matches_regex" => {
1242 if let Some(expected) = &assertion.value {
1243 let kotlin_val = json_to_kotlin(expected);
1244 let _ = writeln!(
1245 out,
1246 " assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
1247 );
1248 }
1249 }
1250 "not_error" => {
1251 }
1253 "error" => {
1254 }
1256 "method_result" => {
1257 let _ = writeln!(
1259 out,
1260 " // method_result assertions not yet implemented for Kotlin"
1261 );
1262 }
1263 other => {
1264 panic!("Kotlin e2e generator: unsupported assertion type: {other}");
1265 }
1266 }
1267}
1268
1269fn json_to_kotlin(value: &serde_json::Value) -> String {
1271 match value {
1272 serde_json::Value::String(s) => format!("\"{}\"", escape_kotlin(s)),
1273 serde_json::Value::Bool(b) => b.to_string(),
1274 serde_json::Value::Number(n) => {
1275 if n.is_f64() {
1276 format!("{}d", n)
1277 } else {
1278 n.to_string()
1279 }
1280 }
1281 serde_json::Value::Null => "null".to_string(),
1282 serde_json::Value::Array(arr) => {
1283 let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
1284 format!("listOf({})", items.join(", "))
1285 }
1286 serde_json::Value::Object(_) => {
1287 let json_str = serde_json::to_string(value).unwrap_or_default();
1288 format!("\"{}\"", escape_kotlin(&json_str))
1289 }
1290 }
1291}