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 client_factory = call_overrides.and_then(|o| o.client_factory.as_deref());
817
818 let effective_function_name = call_overrides
819 .and_then(|o| o.function.as_ref())
820 .cloned()
821 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
822 let effective_result_var = &call_config.result_var;
823 let effective_args = &call_config.args;
824 let function_name = effective_function_name.as_str();
825 let result_var = effective_result_var.as_str();
826 let args: &[crate::config::ArgMapping] = effective_args.as_slice();
827
828 let method_name = fixture.id.to_upper_camel_case();
829 let description = &fixture.description;
830 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
831
832 let needs_deser = options_type.is_some()
834 && args
835 .iter()
836 .any(|arg| arg.arg_type == "json_object" && fixture.input.get(&arg.field).is_some_and(|v| !v.is_null()));
837
838 let _ = writeln!(out, " @Test");
839 let _ = writeln!(out, " fun test{method_name}() {{");
840 let _ = writeln!(out, " // {description}");
841
842 if let (true, Some(opts_type)) = (needs_deser, options_type) {
844 for arg in args {
845 if arg.arg_type == "json_object" {
846 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
847 if let Some(val) = fixture.input.get(field) {
848 if !val.is_null() {
849 let normalized = super::normalize_json_keys_to_snake_case(val);
850 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
851 let var_name = &arg.name;
852 let _ = writeln!(
853 out,
854 " val {var_name} = MAPPER.readValue(\"{}\", {opts_type}::class.java)",
855 escape_kotlin(&json_str)
856 );
857 }
858 }
859 }
860 }
861 }
862
863 let (setup_lines, args_str) = build_args_and_setup(&fixture.input, args, class_name, options_type, &fixture.id);
864
865 if let Some(factory) = client_factory {
870 let fixture_id = &fixture.id;
871 let mock_url_expr = format!("System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"");
872 for line in &setup_lines {
873 let _ = writeln!(out, " {line}");
874 }
875 let _ = writeln!(
876 out,
877 " val client = {class_name}.{factory}(apiKey = \"test-key\", baseUrl = {mock_url_expr})"
878 );
879 if expects_error {
880 let _ = writeln!(out, " assertFailsWith<Exception> {{");
881 let _ = writeln!(out, " client.{function_name}({args_str})");
882 let _ = writeln!(out, " }}");
883 let _ = writeln!(out, " client.close()");
884 let _ = writeln!(out, " }}");
885 return;
886 }
887 let _ = writeln!(out, " val {result_var} = client.{function_name}({args_str})");
888 for assertion in &fixture.assertions {
889 render_assertion(
890 out,
891 assertion,
892 result_var,
893 class_name,
894 field_resolver,
895 result_is_simple,
896 enum_fields,
897 );
898 }
899 let _ = writeln!(out, " client.close()");
900 let _ = writeln!(out, " }}");
901 return;
902 }
903
904 if expects_error {
906 let _ = writeln!(out, " assertFailsWith<Exception> {{");
909 for line in &setup_lines {
910 let _ = writeln!(out, " {line}");
911 }
912 let _ = writeln!(out, " {class_name}.{function_name}({args_str})");
913 let _ = writeln!(out, " }}");
914 let _ = writeln!(out, " }}");
915 return;
916 }
917
918 for line in &setup_lines {
919 let _ = writeln!(out, " {line}");
920 }
921
922 let _ = writeln!(
923 out,
924 " val {result_var} = {class_name}.{function_name}({args_str})"
925 );
926
927 for assertion in &fixture.assertions {
928 render_assertion(
929 out,
930 assertion,
931 result_var,
932 class_name,
933 field_resolver,
934 result_is_simple,
935 enum_fields,
936 );
937 }
938
939 let _ = writeln!(out, " }}");
940}
941
942fn build_args_and_setup(
946 input: &serde_json::Value,
947 args: &[crate::config::ArgMapping],
948 class_name: &str,
949 options_type: Option<&str>,
950 fixture_id: &str,
951) -> (Vec<String>, String) {
952 if args.is_empty() {
953 return (Vec::new(), String::new());
954 }
955
956 let mut setup_lines: Vec<String> = Vec::new();
957 let mut parts: Vec<String> = Vec::new();
958
959 for arg in args {
960 if arg.arg_type == "mock_url" {
961 setup_lines.push(format!(
962 "val {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"",
963 arg.name,
964 ));
965 parts.push(arg.name.clone());
966 continue;
967 }
968
969 if arg.arg_type == "handle" {
970 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
971 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
972 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
973 if config_value.is_null()
974 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
975 {
976 setup_lines.push(format!("val {} = {class_name}.{constructor_name}(null)", arg.name,));
977 } else {
978 let json_str = serde_json::to_string(config_value).unwrap_or_default();
979 let name = &arg.name;
980 setup_lines.push(format!(
981 "val {name}Config = MAPPER.readValue(\"{}\", CrawlConfig::class.java)",
982 escape_kotlin(&json_str),
983 ));
984 setup_lines.push(format!(
985 "val {} = {class_name}.{constructor_name}({name}Config)",
986 arg.name,
987 name = name,
988 ));
989 }
990 parts.push(arg.name.clone());
991 continue;
992 }
993
994 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
995 let val = input.get(field);
996 match val {
997 None | Some(serde_json::Value::Null) if arg.optional => {
998 continue;
999 }
1000 None | Some(serde_json::Value::Null) => {
1001 let default_val = match arg.arg_type.as_str() {
1002 "string" => "\"\"".to_string(),
1003 "int" | "integer" => "0".to_string(),
1004 "float" | "number" => "0.0".to_string(),
1005 "bool" | "boolean" => "false".to_string(),
1006 _ => "null".to_string(),
1007 };
1008 parts.push(default_val);
1009 }
1010 Some(v) => {
1011 if arg.arg_type == "json_object" && options_type.is_some() {
1013 parts.push(arg.name.clone());
1014 continue;
1015 }
1016 if arg.arg_type == "bytes" {
1018 let val = json_to_kotlin(v);
1019 parts.push(format!("{val}.toByteArray()"));
1020 continue;
1021 }
1022 parts.push(json_to_kotlin(v));
1023 }
1024 }
1025 }
1026
1027 (setup_lines, parts.join(", "))
1028}
1029
1030fn render_assertion(
1031 out: &mut String,
1032 assertion: &Assertion,
1033 result_var: &str,
1034 _class_name: &str,
1035 field_resolver: &FieldResolver,
1036 result_is_simple: bool,
1037 enum_fields: &HashSet<String>,
1038) {
1039 if let Some(f) = &assertion.field {
1041 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1042 let _ = writeln!(out, " // skipped: field '{{f}}' not available on result type");
1043 return;
1044 }
1045 }
1046
1047 let field_is_enum = assertion
1049 .field
1050 .as_deref()
1051 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1052
1053 let field_expr = if result_is_simple {
1055 result_var.to_string()
1056 } else {
1057 match &assertion.field {
1058 Some(f) if !f.is_empty() => field_resolver.accessor(f, "kotlin", result_var),
1059 _ => result_var.to_string(),
1060 }
1061 };
1062
1063 let field_is_optional = !result_is_simple
1067 && assertion.field.as_deref().filter(|f| !f.is_empty()).is_some_and(|f| {
1068 let resolved = field_resolver.resolve(f);
1069 if field_resolver.has_map_access(f) {
1070 return false;
1071 }
1072 if field_resolver.is_optional(resolved) {
1074 return true;
1075 }
1076 let mut prefix = String::new();
1079 for part in resolved.split('.') {
1080 let key = part.split('[').next().unwrap_or(part);
1082 if !prefix.is_empty() {
1083 prefix.push('.');
1084 }
1085 prefix.push_str(key);
1086 if field_resolver.is_optional(&prefix) {
1087 return true;
1088 }
1089 }
1090 false
1091 });
1092
1093 let string_field_expr = if field_is_optional {
1096 format!("{field_expr}.orEmpty()")
1097 } else {
1098 field_expr.clone()
1099 };
1100
1101 let nonnull_field_expr = if field_is_optional {
1104 format!("{field_expr}!!")
1105 } else {
1106 field_expr.clone()
1107 };
1108
1109 let string_expr = if field_is_enum {
1111 format!("{string_field_expr}.getValue()")
1112 } else {
1113 string_field_expr.clone()
1114 };
1115
1116 match assertion.assertion_type.as_str() {
1117 "equals" => {
1118 if let Some(expected) = &assertion.value {
1119 let kotlin_val = json_to_kotlin(expected);
1120 if expected.is_string() {
1121 let _ = writeln!(out, " assertEquals({kotlin_val}, {string_expr}.trim())");
1122 } else {
1123 let _ = writeln!(out, " assertEquals({kotlin_val}, {nonnull_field_expr})");
1124 }
1125 }
1126 }
1127 "contains" => {
1128 if let Some(expected) = &assertion.value {
1129 let kotlin_val = json_to_kotlin(expected);
1130 let _ = writeln!(
1131 out,
1132 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1133 );
1134 }
1135 }
1136 "contains_all" => {
1137 if let Some(values) = &assertion.values {
1138 for val in values {
1139 let kotlin_val = json_to_kotlin(val);
1140 let _ = writeln!(
1141 out,
1142 " assertTrue({string_expr}.contains({kotlin_val}), \"expected to contain: \" + {kotlin_val})"
1143 );
1144 }
1145 }
1146 }
1147 "not_contains" => {
1148 if let Some(expected) = &assertion.value {
1149 let kotlin_val = json_to_kotlin(expected);
1150 let _ = writeln!(
1151 out,
1152 " assertFalse({string_expr}.contains({kotlin_val}), \"expected NOT to contain: \" + {kotlin_val})"
1153 );
1154 }
1155 }
1156 "not_empty" => {
1157 let _ = writeln!(
1158 out,
1159 " assertFalse({string_field_expr}.isEmpty(), \"expected non-empty value\")"
1160 );
1161 }
1162 "is_empty" => {
1163 let _ = writeln!(
1164 out,
1165 " assertTrue({string_field_expr}.isEmpty(), \"expected empty value\")"
1166 );
1167 }
1168 "contains_any" => {
1169 if let Some(values) = &assertion.values {
1170 let checks: Vec<String> = values
1171 .iter()
1172 .map(|v| {
1173 let kotlin_val = json_to_kotlin(v);
1174 format!("{string_expr}.contains({kotlin_val})")
1175 })
1176 .collect();
1177 let joined = checks.join(" || ");
1178 let _ = writeln!(
1179 out,
1180 " assertTrue({joined}, \"expected to contain at least one of the specified values\")"
1181 );
1182 }
1183 }
1184 "greater_than" => {
1185 if let Some(val) = &assertion.value {
1186 let kotlin_val = json_to_kotlin(val);
1187 let _ = writeln!(
1188 out,
1189 " assertTrue({nonnull_field_expr} > {kotlin_val}, \"expected > {{kotlin_val}}\")"
1190 );
1191 }
1192 }
1193 "less_than" => {
1194 if let Some(val) = &assertion.value {
1195 let kotlin_val = json_to_kotlin(val);
1196 let _ = writeln!(
1197 out,
1198 " assertTrue({nonnull_field_expr} < {kotlin_val}, \"expected < {{kotlin_val}}\")"
1199 );
1200 }
1201 }
1202 "greater_than_or_equal" => {
1203 if let Some(val) = &assertion.value {
1204 let kotlin_val = json_to_kotlin(val);
1205 let _ = writeln!(
1206 out,
1207 " assertTrue({nonnull_field_expr} >= {kotlin_val}, \"expected >= {{kotlin_val}}\")"
1208 );
1209 }
1210 }
1211 "less_than_or_equal" => {
1212 if let Some(val) = &assertion.value {
1213 let kotlin_val = json_to_kotlin(val);
1214 let _ = writeln!(
1215 out,
1216 " assertTrue({nonnull_field_expr} <= {kotlin_val}, \"expected <= {{kotlin_val}}\")"
1217 );
1218 }
1219 }
1220 "starts_with" => {
1221 if let Some(expected) = &assertion.value {
1222 let kotlin_val = json_to_kotlin(expected);
1223 let _ = writeln!(
1224 out,
1225 " assertTrue({string_expr}.startsWith({kotlin_val}), \"expected to start with: \" + {kotlin_val})"
1226 );
1227 }
1228 }
1229 "ends_with" => {
1230 if let Some(expected) = &assertion.value {
1231 let kotlin_val = json_to_kotlin(expected);
1232 let _ = writeln!(
1233 out,
1234 " assertTrue({string_expr}.endsWith({kotlin_val}), \"expected to end with: \" + {kotlin_val})"
1235 );
1236 }
1237 }
1238 "min_length" => {
1239 if let Some(val) = &assertion.value {
1240 if let Some(n) = val.as_u64() {
1241 let _ = writeln!(
1242 out,
1243 " assertTrue({string_field_expr}.length >= {n}, \"expected length >= {n}\")"
1244 );
1245 }
1246 }
1247 }
1248 "max_length" => {
1249 if let Some(val) = &assertion.value {
1250 if let Some(n) = val.as_u64() {
1251 let _ = writeln!(
1252 out,
1253 " assertTrue({string_field_expr}.length <= {n}, \"expected length <= {n}\")"
1254 );
1255 }
1256 }
1257 }
1258 "count_min" => {
1259 if let Some(val) = &assertion.value {
1260 if let Some(n) = val.as_u64() {
1261 let _ = writeln!(
1262 out,
1263 " assertTrue({nonnull_field_expr}.size >= {n}, \"expected at least {n} elements\")"
1264 );
1265 }
1266 }
1267 }
1268 "count_equals" => {
1269 if let Some(val) = &assertion.value {
1270 if let Some(n) = val.as_u64() {
1271 let _ = writeln!(
1272 out,
1273 " assertEquals({n}, {nonnull_field_expr}.size, \"expected exactly {n} elements\")"
1274 );
1275 }
1276 }
1277 }
1278 "is_true" => {
1279 let _ = writeln!(out, " assertTrue({field_expr}, \"expected true\")");
1280 }
1281 "is_false" => {
1282 let _ = writeln!(out, " assertFalse({field_expr}, \"expected false\")");
1283 }
1284 "matches_regex" => {
1285 if let Some(expected) = &assertion.value {
1286 let kotlin_val = json_to_kotlin(expected);
1287 let _ = writeln!(
1288 out,
1289 " assertTrue(Regex({kotlin_val}).containsMatchIn({string_expr}), \"expected value to match regex: \" + {kotlin_val})"
1290 );
1291 }
1292 }
1293 "not_error" => {
1294 }
1296 "error" => {
1297 }
1299 "method_result" => {
1300 let _ = writeln!(
1302 out,
1303 " // method_result assertions not yet implemented for Kotlin"
1304 );
1305 }
1306 other => {
1307 panic!("Kotlin e2e generator: unsupported assertion type: {other}");
1308 }
1309 }
1310}
1311
1312fn json_to_kotlin(value: &serde_json::Value) -> String {
1314 match value {
1315 serde_json::Value::String(s) => format!("\"{}\"", escape_kotlin(s)),
1316 serde_json::Value::Bool(b) => b.to_string(),
1317 serde_json::Value::Number(n) => {
1318 if n.is_f64() {
1319 format!("{}d", n)
1320 } else {
1321 n.to_string()
1322 }
1323 }
1324 serde_json::Value::Null => "null".to_string(),
1325 serde_json::Value::Array(arr) => {
1326 let items: Vec<String> = arr.iter().map(json_to_kotlin).collect();
1327 format!("listOf({})", items.join(", "))
1328 }
1329 serde_json::Value::Object(_) => {
1330 let json_str = serde_json::to_string(value).unwrap_or_default();
1331 format!("\"{}\"", escape_kotlin(&json_str))
1332 }
1333 }
1334}