From 55abe37d393182c91b45d8430fd7e09d91570d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ROMINA=20JULIETA=20SU=C3=81REZ?= Date: Mon, 15 Jun 2026 21:05:08 -0300 Subject: [PATCH 1/4] JsonPatch to dto --- .../evomaster/core/output/dto/DtoWriter.kt | 40 +++++++- .../evomaster/core/output/dto/GeneToDto.kt | 99 +++++++++++++++++++ .../output/service/HttpWsTestCaseWriter.kt | 8 +- .../core/output/dto/DtoWriterJsonPatchTest.kt | 69 +++++++++++++ .../core/output/dto/GeneToDtoJsonPatchTest.kt | 88 +++++++++++++++++ 5 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 core/src/test/kotlin/org/evomaster/core/output/dto/DtoWriterJsonPatchTest.kt create mode 100644 core/src/test/kotlin/org/evomaster/core/output/dto/GeneToDtoJsonPatchTest.kt diff --git a/core/src/main/kotlin/org/evomaster/core/output/dto/DtoWriter.kt b/core/src/main/kotlin/org/evomaster/core/output/dto/DtoWriter.kt index 7a0351a2eb..3f527d6178 100644 --- a/core/src/main/kotlin/org/evomaster/core/output/dto/DtoWriter.kt +++ b/core/src/main/kotlin/org/evomaster/core/output/dto/DtoWriter.kt @@ -23,6 +23,7 @@ import org.evomaster.core.search.gene.numeric.IntegerGene import org.evomaster.core.search.gene.numeric.LongGene import org.evomaster.core.search.gene.placeholder.CycleObjectGene import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene +import org.evomaster.core.search.gene.jsonpatch.JsonPatchPathValueGene import org.evomaster.core.search.gene.regex.RegexGene import org.evomaster.core.search.gene.string.Base64StringGene import org.evomaster.core.search.gene.string.StringGene @@ -119,11 +120,7 @@ class DtoWriter( gene is ObjectGene -> calculateDtoFromObject(gene, actionName) gene is ArrayGene<*> -> calculateDtoFromArray(gene, actionName) gene is FixedMapGene<*, *> -> calculateDtoFromFixedMapGene(gene, actionName) - // TODO: a JsonPatchDocumentGene is currently skipped from DTO collection. Once we decide - // how a JSON Patch document should be rendered when a test case is written (it is not a - // regular object/array DTO but an RFC 6902 array of operations), this should build and - // emit the corresponding DTO instead of returning. - gene is JsonPatchDocumentGene -> return + gene is JsonPatchDocumentGene -> calculateDtoFromJsonPatch(gene) isPrimitiveGene(gene) -> return else -> { throw IllegalStateException("Gene $gene is not supported for DTO payloads for action: $actionName") @@ -131,6 +128,39 @@ class DtoWriter( } } + /** + * Collects the DTO needed to render a JSON Patch document (RFC 6902) as a typed payload. + * + * A single shared [GeneToDto.JSON_PATCH_OPERATION_DTO] class is used for all patch operations, + * holding every field used across the operation types: "op" and "path" (always present), + * "from" (move/copy) and "value" (add/replace/test). The "value" field is typed as the generic + * object type since a JSON Patch value can be any JSON value; fields not used by a given + * operation are left null and skipped on serialization (see @JsonInclude(NON_NULL)). + * + * When an operation carries an object or array value, the corresponding nested DTOs are also + * collected so the value can be rendered as a proper object instead of stringified JSON. + */ + private fun calculateDtoFromJsonPatch(gene: JsonPatchDocumentGene) { + val dtoName = GeneToDto.JSON_PATCH_OPERATION_DTO + val dtoClass = dtoCollector.computeIfAbsent(dtoName) { DtoClass(it) } + dtoClass.addField(GeneToDto.FIELD_OP, DtoField(GeneToDto.FIELD_OP, "String")) + dtoClass.addField(GeneToDto.FIELD_PATH, DtoField(GeneToDto.FIELD_PATH, "String")) + dtoClass.addField(GeneToDto.FIELD_FROM, DtoField(GeneToDto.FIELD_FROM, "String")) + dtoClass.addField(GeneToDto.FIELD_VALUE, DtoField(GeneToDto.FIELD_VALUE, anyType())) + dtoCollector[dtoName] = dtoClass + + gene.operations.filterIsInstance().forEach { operation -> + when (val valueGene = operation.pathValueChoice.activeGene().second.getLeafGene()) { + is ObjectGene -> calculateDtoFromObject(valueGene, GeneToDto.FIELD_VALUE) + is ArrayGene<*> -> calculateDtoFromArray(valueGene, GeneToDto.FIELD_VALUE) + } + } + } + + private fun anyType(): String { + return if (outputFormat.isJava()) "Object" else "Any" + } + private fun calculateDtoFromFixedMapGene(gene: FixedMapGene<*, *>, actionName: String) { val dtoName = TestWriterUtils.safeVariableName(actionName) val dtoClass = dtoCollector.computeIfAbsent(dtoName) { DtoClass(dtoName) } diff --git a/core/src/main/kotlin/org/evomaster/core/output/dto/GeneToDto.kt b/core/src/main/kotlin/org/evomaster/core/output/dto/GeneToDto.kt index bcd2c2299a..22d6fb868b 100644 --- a/core/src/main/kotlin/org/evomaster/core/output/dto/GeneToDto.kt +++ b/core/src/main/kotlin/org/evomaster/core/output/dto/GeneToDto.kt @@ -12,6 +12,11 @@ import org.evomaster.core.search.gene.collection.PairGene import org.evomaster.core.search.gene.datetime.DateGene import org.evomaster.core.search.gene.datetime.DateTimeGene import org.evomaster.core.search.gene.datetime.TimeGene +import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene +import org.evomaster.core.search.gene.jsonpatch.JsonPatchFromPathGene +import org.evomaster.core.search.gene.jsonpatch.JsonPatchOperationGene +import org.evomaster.core.search.gene.jsonpatch.JsonPatchPathOnlyGene +import org.evomaster.core.search.gene.jsonpatch.JsonPatchPathValueGene import org.evomaster.core.search.gene.numeric.DoubleGene import org.evomaster.core.search.gene.numeric.FloatGene import org.evomaster.core.search.gene.numeric.IntegerGene @@ -36,6 +41,19 @@ class GeneToDto( val outputFormat: OutputFormat ) { + companion object { + /** + * Shared DTO class name used to represent a single JSON Patch operation (RFC 6902). + * A single class is enough since all operations share the same set of possible fields; + * fields not used by a given operation are simply left null and skipped on serialization. + */ + const val JSON_PATCH_OPERATION_DTO = "JsonPatchOperation" + const val FIELD_OP = "op" + const val FIELD_PATH = "path" + const val FIELD_FROM = "from" + const val FIELD_VALUE = "value" + } + private val log: Logger = LoggerFactory.getLogger(GeneToDto::class.java) private var dtoOutput: DtoOutput = if (outputFormat.isJava()) { @@ -67,6 +85,7 @@ class GeneToDto( } is ChoiceGene<*> -> TestWriterUtils.safeVariableName(fallback) is FixedMapGene<*,*> -> TestWriterUtils.safeVariableName(fallback) + is JsonPatchDocumentGene -> JSON_PATCH_OPERATION_DTO else -> throw IllegalStateException("Gene $gene is not supported for DTO payloads for action: $fallback") } } @@ -85,10 +104,90 @@ class GeneToDto( is ArrayGene<*> -> getArrayDtoCall(gene, dtoName, counters, null, capitalize) is ChoiceGene<*> -> getDtoCall(gene.activeGene(), dtoName, counters, capitalize) is FixedMapGene<*,*> -> getFixedMapGeneDtoCall(gene, dtoName, counters) + is JsonPatchDocumentGene -> getJsonPatchDtoCall(gene, counters) else -> throw RuntimeException("BUG: Gene $gene (with type ${this::class.java.simpleName}) should not be creating DTOs") } } + /** + * A JSON Patch document (RFC 6902) is rendered as a list of [JSON_PATCH_OPERATION_DTO] objects, + * one per active operation in the document. This mirrors the JSON array structure of the payload + * while keeping the generated test readable and type-safe. + */ + private fun getJsonPatchDtoCall(gene: JsonPatchDocumentGene, counters: MutableList): DtoCall { + val listVarName = "list_${JSON_PATCH_OPERATION_DTO}_${counters.joinToString("_")}" + val result = mutableListOf() + result.add(dtoOutput.getNewListStatement(JSON_PATCH_OPERATION_DTO, listVarName)) + + var operationCounter = 1 + gene.operations.forEach { operation -> + val childCounter = mutableListOf().apply { + addAll(counters) + add(operationCounter++) + } + val operationCall = getJsonPatchOperationCall(operation, childCounter) + result.addAll(operationCall.objectCalls) + result.add(dtoOutput.getAddElementToListStatement(listVarName, operationCall.varName)) + } + + return DtoCall(listVarName, result) + } + + private fun getJsonPatchOperationCall(operation: JsonPatchOperationGene, counters: MutableList): DtoCall { + val varName = "dto_${JSON_PATCH_OPERATION_DTO}_${counters.joinToString("_")}" + val result = mutableListOf() + result.add(dtoOutput.getNewObjectStatement(JSON_PATCH_OPERATION_DTO, varName)) + result.add(dtoOutput.getSetterStatement(varName, FIELD_OP, "\"${operation.operationName}\"")) + + when (operation) { + is JsonPatchPathOnlyGene -> { + result.add(dtoOutput.getSetterStatement(varName, FIELD_PATH, renderLeafValue(operation.pathGene))) + } + is JsonPatchFromPathGene -> { + result.add(dtoOutput.getSetterStatement(varName, FIELD_FROM, renderLeafValue(operation.fromGene))) + result.add(dtoOutput.getSetterStatement(varName, FIELD_PATH, renderLeafValue(operation.pathGene))) + } + is JsonPatchPathValueGene -> { + val pair = operation.pathValueChoice.activeGene() + result.add(dtoOutput.getSetterStatement(varName, FIELD_PATH, renderLeafValue(pair.first))) + setJsonPatchValue(varName, pair.second.getLeafGene(), counters, result) + } + } + + return DtoCall(varName, result) + } + + /** + * Sets the "value" field of a JSON Patch operation. Primitive values are inlined as literals, + * while object and array values reuse the regular DTO/list generation so nested structures + * are rendered as proper objects rather than stringified JSON. + */ + private fun setJsonPatchValue( + varName: String, + valueGene: Gene, + counters: MutableList, + result: MutableList + ) { + when (valueGene) { + is ObjectGene -> { + val childCall = getDtoCall(valueGene, getDtoName(valueGene, FIELD_VALUE, true), counters, true) + result.addAll(childCall.objectCalls) + result.add(dtoOutput.getSetterStatement(varName, FIELD_VALUE, childCall.varName)) + } + is ArrayGene<*> -> { + val childCall = getArrayDtoCall(valueGene, getDtoName(valueGene, FIELD_VALUE, true), counters, FIELD_VALUE, true) + result.addAll(childCall.objectCalls) + result.add(dtoOutput.getSetterStatement(varName, FIELD_VALUE, childCall.varName)) + } + else -> result.add(dtoOutput.getSetterStatement(varName, FIELD_VALUE, renderLeafValue(valueGene))) + } + } + + private fun renderLeafValue(gene: Gene): String { + val leafGene = gene.getLeafGene() + return "${leafGene.getValueAsPrintableString(targetFormat = outputFormat)}${getValueSuffix(leafGene)}" + } + private fun getObjectDtoCall(gene: ObjectGene, dtoName: String, counters: MutableList): DtoCall { val dtoVarName = "dto_${dtoName}_${counters.joinToString("_")}" diff --git a/core/src/main/kotlin/org/evomaster/core/output/service/HttpWsTestCaseWriter.kt b/core/src/main/kotlin/org/evomaster/core/output/service/HttpWsTestCaseWriter.kt index 44e268d9b4..e502bf255b 100644 --- a/core/src/main/kotlin/org/evomaster/core/output/service/HttpWsTestCaseWriter.kt +++ b/core/src/main/kotlin/org/evomaster/core/output/service/HttpWsTestCaseWriter.kt @@ -128,11 +128,13 @@ abstract class HttpWsTestCaseWriter : ApiTestCaseWriter() { val bodyParam = call.parameters.find { p -> p is BodyParam } as BodyParam? if (bodyParam != null && bodyParam.isJson() && payloadIsValidJson(bodyParam)) { val primaryGene = bodyParam.primaryGene() - if (primaryGene.getWrappedGene(JsonPatchDocumentGene::class.java) != null) { - return "" + val actionName = call.getName() + val jsonPatchGene = primaryGene.getWrappedGene(JsonPatchDocumentGene::class.java) + if (jsonPatchGene != null) { + // A JSON Patch document is rendered as a List DTO (RFC 6902). + return generateDtoCall(jsonPatchGene, actionName, lines).varName } val choiceGene = primaryGene.getWrappedGene(ChoiceGene::class.java) - val actionName = call.getName() if (choiceGene != null) { // We only generate DTOs for ChoiceGene objects that contain either an ObjectGene or ArrayGene in their // genes. This check is necessary since when using `example` and `default` entries, diff --git a/core/src/test/kotlin/org/evomaster/core/output/dto/DtoWriterJsonPatchTest.kt b/core/src/test/kotlin/org/evomaster/core/output/dto/DtoWriterJsonPatchTest.kt new file mode 100644 index 0000000000..83433a6c33 --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/output/dto/DtoWriterJsonPatchTest.kt @@ -0,0 +1,69 @@ +package org.evomaster.core.output.dto + +import org.evomaster.core.output.OutputFormat +import org.evomaster.core.output.Termination +import org.evomaster.core.output.naming.RestActionTestCaseUtils.getEvaluatedIndividualWith +import org.evomaster.core.output.naming.RestActionTestCaseUtils.getRestCallAction +import org.evomaster.core.problem.api.param.Param +import org.evomaster.core.problem.rest.data.HttpVerb +import org.evomaster.core.problem.rest.param.BodyParam +import org.evomaster.core.search.Solution +import org.evomaster.core.search.gene.ObjectGene +import org.evomaster.core.search.gene.collection.EnumGene +import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene +import org.evomaster.core.search.gene.numeric.IntegerGene +import org.evomaster.core.search.gene.string.StringGene +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import java.nio.file.Paths +import java.util.Collections.singletonList + +/** + * Verifies that [DtoWriter] collects the shared JsonPatchOperation DTO (RFC 6902) with every + * field used across operation types, so a JSON Patch payload can be written as a DTO. + */ +class DtoWriterJsonPatchTest { + + private val outputTestSuitePath = Paths.get("./target/dto-writer-json-patch-test") + private val testPackage = "test.package" + + private fun jsonPatchBodyParam(): Param { + val schema = ObjectGene("body", listOf(StringGene("name"), IntegerGene("age"))) + val typeGene = EnumGene("contentType", listOf("application/json-patch+json")).apply { index = 0 } + return BodyParam(gene = JsonPatchDocumentGene("patch", schema), typeGene = typeGene) + } + + private fun jsonPatchSolution(): Solution<*> { + val action = getRestCallAction("/items/{id}", HttpVerb.PATCH, mutableListOf(jsonPatchBodyParam())) + val eIndividual = getEvaluatedIndividualWith(action) + return Solution(singletonList(eIndividual), "", "", Termination.NONE, emptyList(), emptyList()) + } + + @Test + fun collectsJsonPatchOperationDtoWithAllFields() { + val dtoWriter = DtoWriter(OutputFormat.KOTLIN_JUNIT_5) + dtoWriter.write(outputTestSuitePath, testPackage, jsonPatchSolution()) + + val dtos = dtoWriter.getCollectedDtos() + val operationDto = dtos[GeneToDto.JSON_PATCH_OPERATION_DTO] + assertNotNull(operationDto, "Expected a ${GeneToDto.JSON_PATCH_OPERATION_DTO} DTO to be collected") + + val fields = operationDto!!.fieldsMap + assertEquals(DtoField(GeneToDto.FIELD_OP, "String"), fields[GeneToDto.FIELD_OP]) + assertEquals(DtoField(GeneToDto.FIELD_PATH, "String"), fields[GeneToDto.FIELD_PATH]) + assertEquals(DtoField(GeneToDto.FIELD_FROM, "String"), fields[GeneToDto.FIELD_FROM]) + // value is the generic object type since a JSON Patch value can be any JSON value + assertEquals(DtoField(GeneToDto.FIELD_VALUE, "Any"), fields[GeneToDto.FIELD_VALUE]) + } + + @Test + fun valueFieldIsObjectForJavaOutput() { + val dtoWriter = DtoWriter(OutputFormat.JAVA_JUNIT_5) + dtoWriter.write(outputTestSuitePath, testPackage, jsonPatchSolution()) + + val operationDto = dtoWriter.getCollectedDtos()[GeneToDto.JSON_PATCH_OPERATION_DTO] + assertNotNull(operationDto) + assertEquals(DtoField(GeneToDto.FIELD_VALUE, "Object"), operationDto!!.fieldsMap[GeneToDto.FIELD_VALUE]) + } +} diff --git a/core/src/test/kotlin/org/evomaster/core/output/dto/GeneToDtoJsonPatchTest.kt b/core/src/test/kotlin/org/evomaster/core/output/dto/GeneToDtoJsonPatchTest.kt new file mode 100644 index 0000000000..c09c75612e --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/output/dto/GeneToDtoJsonPatchTest.kt @@ -0,0 +1,88 @@ +package org.evomaster.core.output.dto + +import org.evomaster.core.output.OutputFormat +import org.evomaster.core.search.gene.ObjectGene +import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene +import org.evomaster.core.search.gene.jsonpatch.JsonPatchFromPathGene +import org.evomaster.core.search.gene.jsonpatch.JsonPatchPathValueGene +import org.evomaster.core.search.gene.numeric.IntegerGene +import org.evomaster.core.search.gene.string.StringGene +import org.evomaster.core.search.service.Randomness +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Verifies that a [JsonPatchDocumentGene] (RFC 6902) is rendered by [GeneToDto] as a + * List instead of stringified JSON. + */ +class GeneToDtoJsonPatchTest { + + private val dtoName = GeneToDto.JSON_PATCH_OPERATION_DTO + + private fun patchDoc(seed: Long = 42L): JsonPatchDocumentGene { + val schema = ObjectGene("body", listOf(StringGene("name"), IntegerGene("age"))) + val doc = JsonPatchDocumentGene("patch", schema) + doc.doInitialize(Randomness().apply { updateSeed(seed) }) + return doc + } + + private fun setOpValue(line: String): String = + Regex("""\.setOp\("(.*?)"\)""").find(line)!!.groupValues[1] + + @Test + fun rendersAsListOfJsonPatchOperationDtosKotlin() { + val doc = patchDoc() + val dtoCall = GeneToDto(OutputFormat.KOTLIN_JUNIT_5).getDtoCall(doc, dtoName, mutableListOf(0), false) + val calls = dtoCall.objectCalls + + assertEquals("list_${dtoName}_0", dtoCall.varName) + assertEquals("val list_${dtoName}_0 = mutableListOf<$dtoName>()", calls.first()) + + val ops = doc.operations + // One object instantiation and one add-to-list per operation + assertEquals(ops.size, calls.count { it.contains("= $dtoName()") }) + assertEquals(ops.size, calls.count { it.startsWith("list_${dtoName}_0.add(dto_${dtoName}_") }) + + // Every operation sets its "op", and the multiset of op names matches the document + val opNamesInCode = calls.filter { it.contains(".setOp(") }.map { setOpValue(it) } + assertEquals(ops.map { it.operationName }.sorted(), opNamesInCode.sorted()) + } + + @Test + fun setsOnlyTheFieldsRelevantToEachOperation() { + val doc = patchDoc() + val calls = GeneToDto(OutputFormat.KOTLIN_JUNIT_5).getDtoCall(doc, dtoName, mutableListOf(0), false).objectCalls + + val ops = doc.operations + // "from" only for move/copy, "value" only for add/replace/test + assertEquals(ops.count { it is JsonPatchFromPathGene }, calls.count { it.contains(".setFrom(") }) + assertEquals(ops.count { it is JsonPatchPathValueGene }, calls.count { it.contains(".setValue(") }) + // "op" and "path" are present in every operation + assertEquals(ops.size, calls.count { it.contains(".setOp(") }) + assertEquals(ops.size, calls.count { it.contains(".setPath(") }) + } + + @Test + fun primitiveValuesAreInlinedAsLiterals() { + // Force every operation type to render; assert string values are quoted and int values are bare + val doc = patchDoc(seed = 7L) + val calls = GeneToDto(OutputFormat.KOTLIN_JUNIT_5).getDtoCall(doc, dtoName, mutableListOf(0), false).objectCalls + + calls.filter { it.contains(".setValue(") }.forEach { line -> + val value = Regex("""\.setValue\((.*)\)""").find(line)!!.groupValues[1] + val isQuotedString = value.startsWith("\"") && value.endsWith("\"") + val isInt = value.toIntOrNull() != null + assertTrue(isQuotedString || isInt, "Unexpected non-literal value rendering: $line") + } + } + + @Test + fun rendersWithJavaSyntax() { + val doc = patchDoc() + val calls = GeneToDto(OutputFormat.JAVA_JUNIT_5).getDtoCall(doc, dtoName, mutableListOf(0), false).objectCalls + + assertEquals("List<$dtoName> list_${dtoName}_0 = new ArrayList<$dtoName>();", calls.first()) + assertTrue(calls.any { it.contains("$dtoName dto_${dtoName}_0_1 = new $dtoName();") }) + } +} From 1af12342ebe421402d505fb8d310eb5eb4296524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ROMINA=20JULIETA=20SU=C3=81REZ?= Date: Mon, 15 Jun 2026 22:39:44 -0300 Subject: [PATCH 2/4] Add external api extract and test --- .../bb/httppatch/HttpPatchApplication.kt | 332 ++++++++++++++++++ .../bb/httppatch/HttpPatchController.kt | 15 + .../spring/rest/bb/httppatch/HttpPatchTest.kt | 71 ++++ 3 files changed, 418 insertions(+) create mode 100644 core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/httppatch/HttpPatchApplication.kt create mode 100644 core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/com/foo/rest/examples/bb/httppatch/HttpPatchController.kt create mode 100644 core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/httppatch/HttpPatchTest.kt diff --git a/core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/httppatch/HttpPatchApplication.kt b/core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/httppatch/HttpPatchApplication.kt new file mode 100644 index 0000000000..a54855aa68 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/httppatch/HttpPatchApplication.kt @@ -0,0 +1,332 @@ +package com.foo.rest.examples.bb.httppatch + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.ObjectNode +import org.evomaster.e2etests.utils.CoveredTargets +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import java.net.URI + +/** + * Black-box SUT for the JSON Patch (RFC 6902) e2e test. + * + * This is a vendored, self-contained slice of the open-source project + * https://github.com/cassiomolin/http-patch-spring (the same API whose OpenAPI schema is + * already committed in core/src/test/resources/swagger/sut/http-patch-spring.json). + * + * Differences from the original, on purpose: + * - in-memory store instead of a real database, so state can be reset between runs; + * - JSON Patch applied manually with Jackson (no extra dependency); + * - the patch is applied TRANSACTIONALLY: it is computed on a copy, the result is validated, + * and only persisted if the resulting Contact is still valid. Any malformed/inapplicable + * patch, or a patch that would leave the resource invalid, is rejected with a 4xx and the + * stored object is left untouched. This is what guarantees the API "does not break" while + * EvoMaster fuzzes it with potentially destructive patches. + */ +@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) +@RestController +@RequestMapping("/contacts") +open class HttpPatchApplication { + + companion object { + @JvmStatic + fun main(args: Array) { + SpringApplication.run(HttpPatchApplication::class.java, *args) + } + + private val store = LinkedHashMap() + private var counter = 0L + + private fun initialContacts(): List = listOf( + Contact( + name = "Ada Lovelace", + birthday = "1815-12-10", + favorite = true, + notes = "mathematician", + groups = mutableListOf("friends", "science"), + work = Work("Analyst", "Analytical Engine Co"), + phones = mutableListOf(Phone("555-0001", "home")), + emails = mutableListOf(Email("ada@example.com", "work")) + ), + // Note: 'work' is intentionally null here, so patches targeting /work/title + // exercise the "navigate into a missing parent" path (handled as 409, not 500). + Contact( + name = "Alan Turing", + birthday = "1912-06-23", + favorite = false, + notes = null, + groups = mutableListOf("science"), + work = null, + phones = mutableListOf(), + emails = mutableListOf() + ) + ) + + /** + * Re-seeds the in-memory store to its initial state. Invoked by the EmbeddedSutController + * (HttpPatchController.resetStateOfSUT) so that destructive patches from one test do not + * leak into the next one. + */ + @JvmStatic + fun reset() { + synchronized(store) { + store.clear() + counter = 0 + for (c in initialContacts()) { + val id = ++counter + store[id] = c.copy(id = id) + } + } + } + } + + init { + reset() + } + + private val mapper = ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + /** + * All non-2xx responses use a small JSON body. Returning a plain string under + * Content-Type application/json would be served as invalid JSON, which (a) is itself a + * response/schema-mismatch fault and (b) makes the generated black-box tests crash on + * JSON.parse (e.g. JS/Jest with superagent). A JSON object keeps every response valid JSON. + */ + private fun problem(status: Int, message: String): ResponseEntity = + ResponseEntity.status(status).body(mapOf("message" to message)) + + @GetMapping + fun findContacts(): ResponseEntity> = + ResponseEntity.ok(synchronized(store) { store.values.toList() }) + + @PostMapping(consumes = ["application/json"], produces = ["application/json"]) + fun createContact(@RequestBody contact: Contact): ResponseEntity { + if (contact.name.isNullOrBlank()) + return problem(422, "name is required") + val created = synchronized(store) { + val id = ++counter + contact.copy(id = id).also { store[id] = it } + } + // 201 + Location header so EvoMaster can bind subsequent calls (incl. its cleanup DELETE) + // to the created resource. Without this, cleanup hits a non-existent id and EvoMaster's + // BlackBoxRestFitness.handleCleanUpActions fails with "Wrong status: 404". + return ResponseEntity.created(URI.create("/contacts/${created.id}")).body(created) + } + + @GetMapping("/{id}", produces = ["application/json"]) + fun findContact(@PathVariable id: Long): ResponseEntity { + val contact = synchronized(store) { store[id] } ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(contact) + } + + @PutMapping("/{id}", consumes = ["application/json"]) + fun updateContact(@PathVariable id: Long, @RequestBody contact: Contact): ResponseEntity { + synchronized(store) { store[id] } ?: return ResponseEntity.notFound().build() + if (contact.name.isNullOrBlank()) + return problem(422, "name is required") + synchronized(store) { store[id] = contact.copy(id = id) } + return ResponseEntity.ok().build() + } + + // Idempotent on purpose: returns 204 whether or not the id existed. EvoMaster's black-box + // cleanup phase (BlackBoxRestFitness.handleCleanUpActions) issues a DELETE bound to each + // created resource and asserts a 2xx/403 status. Returning 404 for a missing id there would + // crash the whole search with "Wrong status: 404", so we keep DELETE idempotent (also valid + // per RFC 7231: a 2xx with no body is acceptable for an already-absent resource). + @DeleteMapping("/{id}") + fun deleteContact(@PathVariable id: Long): ResponseEntity { + synchronized(store) { store.remove(id) } + return ResponseEntity.noContent().build() + } + + @PatchMapping("/{id}", consumes = ["application/json-patch+json"], produces = ["application/json"]) + fun patchContact(@PathVariable id: Long, @RequestBody body: String): ResponseEntity { + val current = synchronized(store) { store[id] } ?: return ResponseEntity.notFound().build() + + val patchNode = try { + mapper.readTree(body) + } catch (e: Exception) { + return problem(400, "Malformed JSON document") + } + + // Apply on a tree copy of the resource. Any structural problem -> 4xx, never 500. + val patched: JsonNode = try { + ContactJsonPatch.apply(mapper.valueToTree(current), patchNode) + } catch (e: PatchException) { + if (e.status == 409) CoveredTargets.cover("JSONPATCH_CONFLICT") + return problem(e.status, e.message ?: "Patch operation failed") + } catch (e: Exception) { + return problem(400, "Could not apply patch document") + } + + // Validate the result before persisting it. + val updated = try { + mapper.treeToValue(patched, Contact::class.java) + } catch (e: Exception) { + CoveredTargets.cover("JSONPATCH_INVALID_RESOURCE") + return problem(422, "Patched resource is not a valid Contact") + } + if (updated.name.isNullOrBlank()) { + CoveredTargets.cover("JSONPATCH_INVALID_RESOURCE") + return problem(422, "name is required") + } + + synchronized(store) { store[id] = updated.copy(id = id) } + CoveredTargets.cover("JSONPATCH_APPLIED_OK") + return ResponseEntity.ok(updated.copy(id = id)) + } +} + +/** + * Minimal manual JSON Patch (RFC 6902) applier over a Jackson tree. + * Mutates a deep copy of the resource in place. Throws [PatchException] (a 4xx) for any + * malformed or inapplicable operation, so the controller never returns a 500 for bad input. + */ +object ContactJsonPatch { + + fun apply(resource: JsonNode, patch: JsonNode): JsonNode { + if (patch !is ArrayNode) throw PatchException(400, "Patch document must be a JSON array") + + val root: JsonNode = resource.deepCopy() + for (op in patch) { + if (op !is ObjectNode) throw PatchException(400, "Each operation must be a JSON object") + when (val opName = op.path("op").asText(null) + ?: throw PatchException(400, "Operation is missing 'op'")) { + "add" -> { + CoveredTargets.cover("JSONPATCH_OP_ADD") + add(root, pointer(op, "path"), requireValue(op)) + } + "remove" -> { + CoveredTargets.cover("JSONPATCH_OP_REMOVE") + remove(root, pointer(op, "path")) + } + "replace" -> { + CoveredTargets.cover("JSONPATCH_OP_REPLACE") + replace(root, pointer(op, "path"), requireValue(op)) + } + "move" -> { + CoveredTargets.cover("JSONPATCH_OP_MOVE") + val from = pointer(op, "from") + val value = read(root, from) + remove(root, from) + add(root, pointer(op, "path"), value) + } + "copy" -> { + CoveredTargets.cover("JSONPATCH_OP_COPY") + val value = read(root, pointer(op, "from")).deepCopy() + add(root, pointer(op, "path"), value) + } + "test" -> { + CoveredTargets.cover("JSONPATCH_OP_TEST") + if (read(root, pointer(op, "path")) != requireValue(op)) + throw PatchException(409, "Test operation failed") + } + else -> throw PatchException(400, "Unsupported operation: $opName") + } + } + return root + } + + private fun pointer(op: ObjectNode, field: String): String = + op.path(field).asText(null) ?: throw PatchException(400, "Operation is missing '$field'") + + private fun requireValue(op: ObjectNode): JsonNode = + if (op.has("value")) op.get("value") else throw PatchException(400, "Operation is missing 'value'") + + private fun tokens(p: String): List { + if (p.isEmpty()) return emptyList() + if (!p.startsWith("/")) throw PatchException(400, "Invalid JSON Pointer: $p") + return p.substring(1).split("/").map { it.replace("~1", "/").replace("~0", "~") } + } + + private fun child(node: JsonNode, token: String): JsonNode? = when (node) { + is ObjectNode -> if (node.has(token)) node.get(token) else null + is ArrayNode -> token.toIntOrNull()?.let { if (it in 0 until node.size()) node.get(it) else null } + else -> null + } + + private fun parentAndKey(root: JsonNode, p: String): Pair { + val tk = tokens(p) + if (tk.isEmpty()) throw PatchException(400, "Root path '' is not supported") + var node = root + for (i in 0 until tk.size - 1) { + node = child(node, tk[i]) ?: throw PatchException(409, "Path not found: $p") + } + return node to tk.last() + } + + private fun read(root: JsonNode, p: String): JsonNode { + var node = root + for (t in tokens(p)) node = child(node, t) ?: throw PatchException(409, "Path not found: $p") + return node + } + + private fun add(root: JsonNode, p: String, value: JsonNode) { + val (parent, key) = parentAndKey(root, p) + when (parent) { + is ObjectNode -> parent.replace(key, value) + is ArrayNode -> { + if (key == "-") parent.add(value) + else { + val idx = key.toIntOrNull() ?: throw PatchException(400, "Invalid array index: $key") + if (idx < 0 || idx > parent.size()) throw PatchException(409, "Array index out of bounds: $idx") + parent.insert(idx, value) + } + } + else -> throw PatchException(409, "Cannot add into a non-container at $p") + } + } + + private fun remove(root: JsonNode, p: String) { + val (parent, key) = parentAndKey(root, p) + when (parent) { + is ObjectNode -> if (parent.has(key)) parent.remove(key) else throw PatchException(409, "Path not found: $p") + is ArrayNode -> { + val idx = key.toIntOrNull() ?: throw PatchException(400, "Invalid array index: $key") + if (idx < 0 || idx >= parent.size()) throw PatchException(409, "Array index out of bounds: $idx") + parent.remove(idx) + } + else -> throw PatchException(409, "Cannot remove from a non-container at $p") + } + } + + private fun replace(root: JsonNode, p: String, value: JsonNode) { + val (parent, key) = parentAndKey(root, p) + when (parent) { + is ObjectNode -> if (parent.has(key)) parent.replace(key, value) else throw PatchException(409, "Path not found: $p") + is ArrayNode -> { + val idx = key.toIntOrNull() ?: throw PatchException(400, "Invalid array index: $key") + if (idx < 0 || idx >= parent.size()) throw PatchException(409, "Array index out of bounds: $idx") + parent.set(idx, value) + } + else -> throw PatchException(409, "Cannot replace in a non-container at $p") + } + } +} + +class PatchException(val status: Int, message: String) : RuntimeException(message) + +data class Contact( + var id: Long? = null, + var name: String? = null, + var birthday: String? = null, + var favorite: Boolean? = null, + var notes: String? = null, + var groups: MutableList? = null, + var work: Work? = null, + var phones: MutableList? = null, + var emails: MutableList? = null +) + +data class Work(var title: String? = null, var company: String? = null) + +data class Phone(var phone: String? = null, var type: String? = null) + +data class Email(var email: String? = null, var type: String? = null) \ No newline at end of file diff --git a/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/com/foo/rest/examples/bb/httppatch/HttpPatchController.kt b/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/com/foo/rest/examples/bb/httppatch/HttpPatchController.kt new file mode 100644 index 0000000000..a4e309f9d4 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/com/foo/rest/examples/bb/httppatch/HttpPatchController.kt @@ -0,0 +1,15 @@ +package com.foo.rest.examples.bb.httppatch + +import com.foo.rest.examples.bb.SpringController + +class HttpPatchController : SpringController(HttpPatchApplication::class.java) { + + /** + * Re-seed the in-memory store before each EvoMaster action sequence, so that destructive + * JSON Patch documents (e.g. removing a required field) cannot corrupt the state used by + * subsequent calls. This is what keeps the e2e test deterministic and the SUT robust. + */ + override fun resetStateOfSUT() { + HttpPatchApplication.reset() + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/httppatch/HttpPatchTest.kt b/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/httppatch/HttpPatchTest.kt new file mode 100644 index 0000000000..26fc86e67c --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/httppatch/HttpPatchTest.kt @@ -0,0 +1,71 @@ +package org.evomaster.e2etests.spring.rest.bb.httppatch + +import com.foo.rest.examples.bb.httppatch.HttpPatchController +import org.evomaster.core.EMConfig +import org.evomaster.core.output.OutputFormat +import org.evomaster.core.problem.rest.data.HttpVerb +import org.evomaster.e2etests.spring.rest.bb.SpringTestBase +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource + +/** + * End-to-end test for JSON Patch (RFC 6902) against a realistic, vendored API + * (a slice of https://github.com/cassiomolin/http-patch-spring, exposing PATCH /contacts/{id} + * with media type application/json-patch+json). + * + * Unlike BBJsonPatchTest -- which uses a synthetic SUT that only checks the *presence* of each + * operation and never mutates state -- here the patch is actually applied to a real Contact + * resource. This exercises: + * - resolving the resource schema from the sibling GET /contacts/{id} response, so generated + * paths/values reference real fields (/name, /notes, /favorite, /work/title, ...); + * - that EvoMaster covers all six operations (add/remove/replace/move/copy/test); + * - that a patch which would leave the resource invalid (e.g. removing the required name) is + * detected and rejected (422) without corrupting state -> JSONPATCH_INVALID_RESOURCE. + */ +class HttpPatchTest : SpringTestBase() { + + companion object { + @BeforeAll + @JvmStatic + fun init() { + val config = EMConfig() + initClass(HttpPatchController(), config) + } + } + + @ParameterizedTest + @EnumSource + fun testBlackBoxOutput(outputFormat: OutputFormat) { + executeAndEvaluateBBTest( + outputFormat, + "HttpPatchEM", + 1000, + 3, + listOf( + "JSONPATCH_OP_ADD", + "JSONPATCH_OP_REMOVE", + "JSONPATCH_OP_REPLACE", + "JSONPATCH_OP_MOVE", + "JSONPATCH_OP_COPY", + "JSONPATCH_OP_TEST", + "JSONPATCH_APPLIED_OK", + "JSONPATCH_INVALID_RESOURCE", + "JSONPATCH_CONFLICT" + ) + ) { args: MutableList -> + + val solution = initAndRun(args) + + assertTrue(solution.individuals.size >= 1) + + // A patch that applies cleanly and persists. + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/contacts/{id}", null) + // A patch that would leave the resource invalid -> rejected, state untouched. + assertHasAtLeastOne(solution, HttpVerb.PATCH, 422, "/contacts/{id}", null) + // A structurally inapplicable patch (bad path / failed test op) -> conflict. + assertHasAtLeastOne(solution, HttpVerb.PATCH, 409, "/contacts/{id}", null) + } + } +} From 2f1f033a1fb7cca9ceaf32b076b2ed18a415f3df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ROMINA=20JULIETA=20SU=C3=81REZ?= Date: Fri, 19 Jun 2026 23:28:17 -0300 Subject: [PATCH 3/4] externalApi is not for this pr --- .../bb/httppatch/HttpPatchApplication.kt | 332 ------------------ .../bb/httppatch/HttpPatchController.kt | 15 - .../spring/rest/bb/httppatch/HttpPatchTest.kt | 71 ---- 3 files changed, 418 deletions(-) delete mode 100644 core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/httppatch/HttpPatchApplication.kt delete mode 100644 core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/com/foo/rest/examples/bb/httppatch/HttpPatchController.kt delete mode 100644 core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/httppatch/HttpPatchTest.kt diff --git a/core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/httppatch/HttpPatchApplication.kt b/core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/httppatch/HttpPatchApplication.kt deleted file mode 100644 index a54855aa68..0000000000 --- a/core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/httppatch/HttpPatchApplication.kt +++ /dev/null @@ -1,332 +0,0 @@ -package com.foo.rest.examples.bb.httppatch - -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.node.ArrayNode -import com.fasterxml.jackson.databind.node.ObjectNode -import org.evomaster.e2etests.utils.CoveredTargets -import org.springframework.boot.SpringApplication -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.* -import java.net.URI - -/** - * Black-box SUT for the JSON Patch (RFC 6902) e2e test. - * - * This is a vendored, self-contained slice of the open-source project - * https://github.com/cassiomolin/http-patch-spring (the same API whose OpenAPI schema is - * already committed in core/src/test/resources/swagger/sut/http-patch-spring.json). - * - * Differences from the original, on purpose: - * - in-memory store instead of a real database, so state can be reset between runs; - * - JSON Patch applied manually with Jackson (no extra dependency); - * - the patch is applied TRANSACTIONALLY: it is computed on a copy, the result is validated, - * and only persisted if the resulting Contact is still valid. Any malformed/inapplicable - * patch, or a patch that would leave the resource invalid, is rejected with a 4xx and the - * stored object is left untouched. This is what guarantees the API "does not break" while - * EvoMaster fuzzes it with potentially destructive patches. - */ -@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) -@RestController -@RequestMapping("/contacts") -open class HttpPatchApplication { - - companion object { - @JvmStatic - fun main(args: Array) { - SpringApplication.run(HttpPatchApplication::class.java, *args) - } - - private val store = LinkedHashMap() - private var counter = 0L - - private fun initialContacts(): List = listOf( - Contact( - name = "Ada Lovelace", - birthday = "1815-12-10", - favorite = true, - notes = "mathematician", - groups = mutableListOf("friends", "science"), - work = Work("Analyst", "Analytical Engine Co"), - phones = mutableListOf(Phone("555-0001", "home")), - emails = mutableListOf(Email("ada@example.com", "work")) - ), - // Note: 'work' is intentionally null here, so patches targeting /work/title - // exercise the "navigate into a missing parent" path (handled as 409, not 500). - Contact( - name = "Alan Turing", - birthday = "1912-06-23", - favorite = false, - notes = null, - groups = mutableListOf("science"), - work = null, - phones = mutableListOf(), - emails = mutableListOf() - ) - ) - - /** - * Re-seeds the in-memory store to its initial state. Invoked by the EmbeddedSutController - * (HttpPatchController.resetStateOfSUT) so that destructive patches from one test do not - * leak into the next one. - */ - @JvmStatic - fun reset() { - synchronized(store) { - store.clear() - counter = 0 - for (c in initialContacts()) { - val id = ++counter - store[id] = c.copy(id = id) - } - } - } - } - - init { - reset() - } - - private val mapper = ObjectMapper() - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - - /** - * All non-2xx responses use a small JSON body. Returning a plain string under - * Content-Type application/json would be served as invalid JSON, which (a) is itself a - * response/schema-mismatch fault and (b) makes the generated black-box tests crash on - * JSON.parse (e.g. JS/Jest with superagent). A JSON object keeps every response valid JSON. - */ - private fun problem(status: Int, message: String): ResponseEntity = - ResponseEntity.status(status).body(mapOf("message" to message)) - - @GetMapping - fun findContacts(): ResponseEntity> = - ResponseEntity.ok(synchronized(store) { store.values.toList() }) - - @PostMapping(consumes = ["application/json"], produces = ["application/json"]) - fun createContact(@RequestBody contact: Contact): ResponseEntity { - if (contact.name.isNullOrBlank()) - return problem(422, "name is required") - val created = synchronized(store) { - val id = ++counter - contact.copy(id = id).also { store[id] = it } - } - // 201 + Location header so EvoMaster can bind subsequent calls (incl. its cleanup DELETE) - // to the created resource. Without this, cleanup hits a non-existent id and EvoMaster's - // BlackBoxRestFitness.handleCleanUpActions fails with "Wrong status: 404". - return ResponseEntity.created(URI.create("/contacts/${created.id}")).body(created) - } - - @GetMapping("/{id}", produces = ["application/json"]) - fun findContact(@PathVariable id: Long): ResponseEntity { - val contact = synchronized(store) { store[id] } ?: return ResponseEntity.notFound().build() - return ResponseEntity.ok(contact) - } - - @PutMapping("/{id}", consumes = ["application/json"]) - fun updateContact(@PathVariable id: Long, @RequestBody contact: Contact): ResponseEntity { - synchronized(store) { store[id] } ?: return ResponseEntity.notFound().build() - if (contact.name.isNullOrBlank()) - return problem(422, "name is required") - synchronized(store) { store[id] = contact.copy(id = id) } - return ResponseEntity.ok().build() - } - - // Idempotent on purpose: returns 204 whether or not the id existed. EvoMaster's black-box - // cleanup phase (BlackBoxRestFitness.handleCleanUpActions) issues a DELETE bound to each - // created resource and asserts a 2xx/403 status. Returning 404 for a missing id there would - // crash the whole search with "Wrong status: 404", so we keep DELETE idempotent (also valid - // per RFC 7231: a 2xx with no body is acceptable for an already-absent resource). - @DeleteMapping("/{id}") - fun deleteContact(@PathVariable id: Long): ResponseEntity { - synchronized(store) { store.remove(id) } - return ResponseEntity.noContent().build() - } - - @PatchMapping("/{id}", consumes = ["application/json-patch+json"], produces = ["application/json"]) - fun patchContact(@PathVariable id: Long, @RequestBody body: String): ResponseEntity { - val current = synchronized(store) { store[id] } ?: return ResponseEntity.notFound().build() - - val patchNode = try { - mapper.readTree(body) - } catch (e: Exception) { - return problem(400, "Malformed JSON document") - } - - // Apply on a tree copy of the resource. Any structural problem -> 4xx, never 500. - val patched: JsonNode = try { - ContactJsonPatch.apply(mapper.valueToTree(current), patchNode) - } catch (e: PatchException) { - if (e.status == 409) CoveredTargets.cover("JSONPATCH_CONFLICT") - return problem(e.status, e.message ?: "Patch operation failed") - } catch (e: Exception) { - return problem(400, "Could not apply patch document") - } - - // Validate the result before persisting it. - val updated = try { - mapper.treeToValue(patched, Contact::class.java) - } catch (e: Exception) { - CoveredTargets.cover("JSONPATCH_INVALID_RESOURCE") - return problem(422, "Patched resource is not a valid Contact") - } - if (updated.name.isNullOrBlank()) { - CoveredTargets.cover("JSONPATCH_INVALID_RESOURCE") - return problem(422, "name is required") - } - - synchronized(store) { store[id] = updated.copy(id = id) } - CoveredTargets.cover("JSONPATCH_APPLIED_OK") - return ResponseEntity.ok(updated.copy(id = id)) - } -} - -/** - * Minimal manual JSON Patch (RFC 6902) applier over a Jackson tree. - * Mutates a deep copy of the resource in place. Throws [PatchException] (a 4xx) for any - * malformed or inapplicable operation, so the controller never returns a 500 for bad input. - */ -object ContactJsonPatch { - - fun apply(resource: JsonNode, patch: JsonNode): JsonNode { - if (patch !is ArrayNode) throw PatchException(400, "Patch document must be a JSON array") - - val root: JsonNode = resource.deepCopy() - for (op in patch) { - if (op !is ObjectNode) throw PatchException(400, "Each operation must be a JSON object") - when (val opName = op.path("op").asText(null) - ?: throw PatchException(400, "Operation is missing 'op'")) { - "add" -> { - CoveredTargets.cover("JSONPATCH_OP_ADD") - add(root, pointer(op, "path"), requireValue(op)) - } - "remove" -> { - CoveredTargets.cover("JSONPATCH_OP_REMOVE") - remove(root, pointer(op, "path")) - } - "replace" -> { - CoveredTargets.cover("JSONPATCH_OP_REPLACE") - replace(root, pointer(op, "path"), requireValue(op)) - } - "move" -> { - CoveredTargets.cover("JSONPATCH_OP_MOVE") - val from = pointer(op, "from") - val value = read(root, from) - remove(root, from) - add(root, pointer(op, "path"), value) - } - "copy" -> { - CoveredTargets.cover("JSONPATCH_OP_COPY") - val value = read(root, pointer(op, "from")).deepCopy() - add(root, pointer(op, "path"), value) - } - "test" -> { - CoveredTargets.cover("JSONPATCH_OP_TEST") - if (read(root, pointer(op, "path")) != requireValue(op)) - throw PatchException(409, "Test operation failed") - } - else -> throw PatchException(400, "Unsupported operation: $opName") - } - } - return root - } - - private fun pointer(op: ObjectNode, field: String): String = - op.path(field).asText(null) ?: throw PatchException(400, "Operation is missing '$field'") - - private fun requireValue(op: ObjectNode): JsonNode = - if (op.has("value")) op.get("value") else throw PatchException(400, "Operation is missing 'value'") - - private fun tokens(p: String): List { - if (p.isEmpty()) return emptyList() - if (!p.startsWith("/")) throw PatchException(400, "Invalid JSON Pointer: $p") - return p.substring(1).split("/").map { it.replace("~1", "/").replace("~0", "~") } - } - - private fun child(node: JsonNode, token: String): JsonNode? = when (node) { - is ObjectNode -> if (node.has(token)) node.get(token) else null - is ArrayNode -> token.toIntOrNull()?.let { if (it in 0 until node.size()) node.get(it) else null } - else -> null - } - - private fun parentAndKey(root: JsonNode, p: String): Pair { - val tk = tokens(p) - if (tk.isEmpty()) throw PatchException(400, "Root path '' is not supported") - var node = root - for (i in 0 until tk.size - 1) { - node = child(node, tk[i]) ?: throw PatchException(409, "Path not found: $p") - } - return node to tk.last() - } - - private fun read(root: JsonNode, p: String): JsonNode { - var node = root - for (t in tokens(p)) node = child(node, t) ?: throw PatchException(409, "Path not found: $p") - return node - } - - private fun add(root: JsonNode, p: String, value: JsonNode) { - val (parent, key) = parentAndKey(root, p) - when (parent) { - is ObjectNode -> parent.replace(key, value) - is ArrayNode -> { - if (key == "-") parent.add(value) - else { - val idx = key.toIntOrNull() ?: throw PatchException(400, "Invalid array index: $key") - if (idx < 0 || idx > parent.size()) throw PatchException(409, "Array index out of bounds: $idx") - parent.insert(idx, value) - } - } - else -> throw PatchException(409, "Cannot add into a non-container at $p") - } - } - - private fun remove(root: JsonNode, p: String) { - val (parent, key) = parentAndKey(root, p) - when (parent) { - is ObjectNode -> if (parent.has(key)) parent.remove(key) else throw PatchException(409, "Path not found: $p") - is ArrayNode -> { - val idx = key.toIntOrNull() ?: throw PatchException(400, "Invalid array index: $key") - if (idx < 0 || idx >= parent.size()) throw PatchException(409, "Array index out of bounds: $idx") - parent.remove(idx) - } - else -> throw PatchException(409, "Cannot remove from a non-container at $p") - } - } - - private fun replace(root: JsonNode, p: String, value: JsonNode) { - val (parent, key) = parentAndKey(root, p) - when (parent) { - is ObjectNode -> if (parent.has(key)) parent.replace(key, value) else throw PatchException(409, "Path not found: $p") - is ArrayNode -> { - val idx = key.toIntOrNull() ?: throw PatchException(400, "Invalid array index: $key") - if (idx < 0 || idx >= parent.size()) throw PatchException(409, "Array index out of bounds: $idx") - parent.set(idx, value) - } - else -> throw PatchException(409, "Cannot replace in a non-container at $p") - } - } -} - -class PatchException(val status: Int, message: String) : RuntimeException(message) - -data class Contact( - var id: Long? = null, - var name: String? = null, - var birthday: String? = null, - var favorite: Boolean? = null, - var notes: String? = null, - var groups: MutableList? = null, - var work: Work? = null, - var phones: MutableList? = null, - var emails: MutableList? = null -) - -data class Work(var title: String? = null, var company: String? = null) - -data class Phone(var phone: String? = null, var type: String? = null) - -data class Email(var email: String? = null, var type: String? = null) \ No newline at end of file diff --git a/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/com/foo/rest/examples/bb/httppatch/HttpPatchController.kt b/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/com/foo/rest/examples/bb/httppatch/HttpPatchController.kt deleted file mode 100644 index a4e309f9d4..0000000000 --- a/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/com/foo/rest/examples/bb/httppatch/HttpPatchController.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.foo.rest.examples.bb.httppatch - -import com.foo.rest.examples.bb.SpringController - -class HttpPatchController : SpringController(HttpPatchApplication::class.java) { - - /** - * Re-seed the in-memory store before each EvoMaster action sequence, so that destructive - * JSON Patch documents (e.g. removing a required field) cannot corrupt the state used by - * subsequent calls. This is what keeps the e2e test deterministic and the SUT robust. - */ - override fun resetStateOfSUT() { - HttpPatchApplication.reset() - } -} diff --git a/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/httppatch/HttpPatchTest.kt b/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/httppatch/HttpPatchTest.kt deleted file mode 100644 index 26fc86e67c..0000000000 --- a/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/httppatch/HttpPatchTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -package org.evomaster.e2etests.spring.rest.bb.httppatch - -import com.foo.rest.examples.bb.httppatch.HttpPatchController -import org.evomaster.core.EMConfig -import org.evomaster.core.output.OutputFormat -import org.evomaster.core.problem.rest.data.HttpVerb -import org.evomaster.e2etests.spring.rest.bb.SpringTestBase -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.EnumSource - -/** - * End-to-end test for JSON Patch (RFC 6902) against a realistic, vendored API - * (a slice of https://github.com/cassiomolin/http-patch-spring, exposing PATCH /contacts/{id} - * with media type application/json-patch+json). - * - * Unlike BBJsonPatchTest -- which uses a synthetic SUT that only checks the *presence* of each - * operation and never mutates state -- here the patch is actually applied to a real Contact - * resource. This exercises: - * - resolving the resource schema from the sibling GET /contacts/{id} response, so generated - * paths/values reference real fields (/name, /notes, /favorite, /work/title, ...); - * - that EvoMaster covers all six operations (add/remove/replace/move/copy/test); - * - that a patch which would leave the resource invalid (e.g. removing the required name) is - * detected and rejected (422) without corrupting state -> JSONPATCH_INVALID_RESOURCE. - */ -class HttpPatchTest : SpringTestBase() { - - companion object { - @BeforeAll - @JvmStatic - fun init() { - val config = EMConfig() - initClass(HttpPatchController(), config) - } - } - - @ParameterizedTest - @EnumSource - fun testBlackBoxOutput(outputFormat: OutputFormat) { - executeAndEvaluateBBTest( - outputFormat, - "HttpPatchEM", - 1000, - 3, - listOf( - "JSONPATCH_OP_ADD", - "JSONPATCH_OP_REMOVE", - "JSONPATCH_OP_REPLACE", - "JSONPATCH_OP_MOVE", - "JSONPATCH_OP_COPY", - "JSONPATCH_OP_TEST", - "JSONPATCH_APPLIED_OK", - "JSONPATCH_INVALID_RESOURCE", - "JSONPATCH_CONFLICT" - ) - ) { args: MutableList -> - - val solution = initAndRun(args) - - assertTrue(solution.individuals.size >= 1) - - // A patch that applies cleanly and persists. - assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/contacts/{id}", null) - // A patch that would leave the resource invalid -> rejected, state untouched. - assertHasAtLeastOne(solution, HttpVerb.PATCH, 422, "/contacts/{id}", null) - // A structurally inapplicable patch (bad path / failed test op) -> conflict. - assertHasAtLeastOne(solution, HttpVerb.PATCH, 409, "/contacts/{id}", null) - } - } -} From fe828308dad98aa1883ba80911b0c37ee395c8a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ROMINA=20JULIETA=20SU=C3=81REZ?= Date: Sat, 20 Jun 2026 00:33:32 -0300 Subject: [PATCH 4/4] Fixes for coments and tests --- .../evomaster/core/output/dto/DtoWriter.kt | 13 +----- .../evomaster/core/output/dto/GeneToDto.kt | 20 ++------ .../core/output/TestCaseWriterTest.kt | 37 +++++++++++++++ .../core/output/dto/DtoWriterJsonPatchTest.kt | 46 +++++++++++++++++-- 4 files changed, 85 insertions(+), 31 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/output/dto/DtoWriter.kt b/core/src/main/kotlin/org/evomaster/core/output/dto/DtoWriter.kt index 3f527d6178..bce25ebc8a 100644 --- a/core/src/main/kotlin/org/evomaster/core/output/dto/DtoWriter.kt +++ b/core/src/main/kotlin/org/evomaster/core/output/dto/DtoWriter.kt @@ -128,18 +128,7 @@ class DtoWriter( } } - /** - * Collects the DTO needed to render a JSON Patch document (RFC 6902) as a typed payload. - * - * A single shared [GeneToDto.JSON_PATCH_OPERATION_DTO] class is used for all patch operations, - * holding every field used across the operation types: "op" and "path" (always present), - * "from" (move/copy) and "value" (add/replace/test). The "value" field is typed as the generic - * object type since a JSON Patch value can be any JSON value; fields not used by a given - * operation are left null and skipped on serialization (see @JsonInclude(NON_NULL)). - * - * When an operation carries an object or array value, the corresponding nested DTOs are also - * collected so the value can be rendered as a proper object instead of stringified JSON. - */ + // Registers the shared JsonPatchOperation DTO and collects nested DTOs for object/array values. private fun calculateDtoFromJsonPatch(gene: JsonPatchDocumentGene) { val dtoName = GeneToDto.JSON_PATCH_OPERATION_DTO val dtoClass = dtoCollector.computeIfAbsent(dtoName) { DtoClass(it) } diff --git a/core/src/main/kotlin/org/evomaster/core/output/dto/GeneToDto.kt b/core/src/main/kotlin/org/evomaster/core/output/dto/GeneToDto.kt index 22d6fb868b..20bb119809 100644 --- a/core/src/main/kotlin/org/evomaster/core/output/dto/GeneToDto.kt +++ b/core/src/main/kotlin/org/evomaster/core/output/dto/GeneToDto.kt @@ -42,11 +42,7 @@ class GeneToDto( ) { companion object { - /** - * Shared DTO class name used to represent a single JSON Patch operation (RFC 6902). - * A single class is enough since all operations share the same set of possible fields; - * fields not used by a given operation are simply left null and skipped on serialization. - */ + // Shared DTO class name for all JSON Patch operations (RFC 6902). const val JSON_PATCH_OPERATION_DTO = "JsonPatchOperation" const val FIELD_OP = "op" const val FIELD_PATH = "path" @@ -109,11 +105,7 @@ class GeneToDto( } } - /** - * A JSON Patch document (RFC 6902) is rendered as a list of [JSON_PATCH_OPERATION_DTO] objects, - * one per active operation in the document. This mirrors the JSON array structure of the payload - * while keeping the generated test readable and type-safe. - */ + // Renders a JSON Patch document as a List, one DTO per active operation. private fun getJsonPatchDtoCall(gene: JsonPatchDocumentGene, counters: MutableList): DtoCall { val listVarName = "list_${JSON_PATCH_OPERATION_DTO}_${counters.joinToString("_")}" val result = mutableListOf() @@ -133,6 +125,7 @@ class GeneToDto( return DtoCall(listVarName, result) } + // Renders a single RFC 6902 operation as a JsonPatchOperation DTO with only its relevant fields set. private fun getJsonPatchOperationCall(operation: JsonPatchOperationGene, counters: MutableList): DtoCall { val varName = "dto_${JSON_PATCH_OPERATION_DTO}_${counters.joinToString("_")}" val result = mutableListOf() @@ -157,11 +150,7 @@ class GeneToDto( return DtoCall(varName, result) } - /** - * Sets the "value" field of a JSON Patch operation. Primitive values are inlined as literals, - * while object and array values reuse the regular DTO/list generation so nested structures - * are rendered as proper objects rather than stringified JSON. - */ + // Sets the "value" field: primitives are inlined as literals, objects/arrays delegate to DTO generation. private fun setJsonPatchValue( varName: String, valueGene: Gene, @@ -183,6 +172,7 @@ class GeneToDto( } } + // Returns the printable string representation of a gene's leaf value with its language-specific suffix. private fun renderLeafValue(gene: Gene): String { val leafGene = gene.getLeafGene() return "${leafGene.getValueAsPrintableString(targetFormat = outputFormat)}${getValueSuffix(leafGene)}" diff --git a/core/src/test/kotlin/org/evomaster/core/output/TestCaseWriterTest.kt b/core/src/test/kotlin/org/evomaster/core/output/TestCaseWriterTest.kt index d2454da456..81523a8733 100644 --- a/core/src/test/kotlin/org/evomaster/core/output/TestCaseWriterTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/output/TestCaseWriterTest.kt @@ -27,8 +27,11 @@ import org.evomaster.core.search.gene.UUIDGene import org.evomaster.core.search.gene.collection.EnumGene import org.evomaster.core.search.gene.numeric.IntegerGene import org.evomaster.core.search.gene.string.StringGene +import org.evomaster.core.output.dto.GeneToDto +import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene import org.evomaster.core.search.gene.utils.GeneUtils import org.evomaster.core.search.gene.wrapper.OptionalGene +import org.evomaster.core.search.service.Randomness import org.evomaster.core.sql.schema.TableId import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test @@ -1641,6 +1644,40 @@ public void test() throws Exception { } } + @Test + fun testJsonPatchBodyRenderedAsDto() { + val format = OutputFormat.KOTLIN_JUNIT_5 + val baseUrlOfSut = "baseUrlOfSut" + + val schema = ObjectGene("body", listOf(StringGene("name"), IntegerGene("age"))) + val typeGene = EnumGene("contentType", listOf("application/json-patch+json")).apply { index = 0 } + val bodyParam = BodyParam(gene = JsonPatchDocumentGene("patch", schema), typeGene = typeGene) + + val action = RestCallAction("1", HttpVerb.PATCH, RestPath("/items/1"), mutableListOf(bodyParam)) + val individual = RestIndividual(mutableListOf(action), SampleType.RANDOM) + TestUtils.doInitializeIndividualForTesting(individual, Randomness().apply { updateSeed(42L) }) + + val fitnessVal = FitnessValue(0.0) + val result = RestCallResult(action.getLocalId()).apply { setStatusCode(200) } + val ei = EvaluatedIndividual(fitnessVal, individual, listOf(result)) + + val config = getConfig(format) + config.dtoForRequestPayload = true + config.problemType = EMConfig.ProblemType.REST + + val test = TestCase(test = ei, name = "test") + val writer = RestTestCaseWriter(config, PartialOracles()) + val output = writer.convertToCompilableTestCode(test, baseUrlOfSut).toString() + + // The JSON Patch body must be rendered as a DTO list, not as a raw JSON string. + assertTrue(output.contains("list_${GeneToDto.JSON_PATCH_OPERATION_DTO}_"), + "Expected DTO list variable in generated output") + assertTrue(output.contains(".body(list_${GeneToDto.JSON_PATCH_OPERATION_DTO}_"), + "Expected DTO variable passed as body argument") + assertFalse(output.contains("{\"op\":"), + "Body must not contain raw JSON string representation of a patch operation") + } + @Test fun testInActiveBodyParamInTest(){ val stringGene = StringGene("stringGene") diff --git a/core/src/test/kotlin/org/evomaster/core/output/dto/DtoWriterJsonPatchTest.kt b/core/src/test/kotlin/org/evomaster/core/output/dto/DtoWriterJsonPatchTest.kt index 83433a6c33..73594607ea 100644 --- a/core/src/test/kotlin/org/evomaster/core/output/dto/DtoWriterJsonPatchTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/output/dto/DtoWriterJsonPatchTest.kt @@ -1,28 +1,34 @@ package org.evomaster.core.output.dto +import org.evomaster.core.TestUtils import org.evomaster.core.output.OutputFormat import org.evomaster.core.output.Termination import org.evomaster.core.output.naming.RestActionTestCaseUtils.getEvaluatedIndividualWith import org.evomaster.core.output.naming.RestActionTestCaseUtils.getRestCallAction import org.evomaster.core.problem.api.param.Param +import org.evomaster.core.problem.enterprise.SampleType import org.evomaster.core.problem.rest.data.HttpVerb +import org.evomaster.core.problem.rest.data.RestCallResult +import org.evomaster.core.problem.rest.data.RestIndividual import org.evomaster.core.problem.rest.param.BodyParam +import org.evomaster.core.search.EvaluatedIndividual +import org.evomaster.core.search.FitnessValue import org.evomaster.core.search.Solution import org.evomaster.core.search.gene.ObjectGene +import org.evomaster.core.search.gene.collection.ArrayGene import org.evomaster.core.search.gene.collection.EnumGene import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene +import org.evomaster.core.search.gene.jsonpatch.JsonPatchPathValueGene import org.evomaster.core.search.gene.numeric.IntegerGene import org.evomaster.core.search.gene.string.StringGene +import org.evomaster.core.search.service.Randomness import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import java.nio.file.Paths import java.util.Collections.singletonList -/** - * Verifies that [DtoWriter] collects the shared JsonPatchOperation DTO (RFC 6902) with every - * field used across operation types, so a JSON Patch payload can be written as a DTO. - */ class DtoWriterJsonPatchTest { private val outputTestSuitePath = Paths.get("./target/dto-writer-json-patch-test") @@ -66,4 +72,36 @@ class DtoWriterJsonPatchTest { assertNotNull(operationDto) assertEquals(DtoField(GeneToDto.FIELD_VALUE, "Object"), operationDto!!.fieldsMap[GeneToDto.FIELD_VALUE]) } + + @Test + fun collectsNestedObjectDtoWhenValueIsArrayOfObjects() { + // Schema where the only patchable field is an array of objects: + // every add/replace/test operation will have an ArrayGene as its value gene, + // so calculateDtoFromJsonPatch must visit calculateDtoFromArray for those operations. + val tagSchema = ObjectGene("Tag", listOf(StringGene("label")), refType = "Tag") + val schema = ObjectGene("body", listOf(ArrayGene("tags", tagSchema))) + val typeGene = EnumGene("contentType", listOf("application/json-patch+json")).apply { index = 0 } + val bodyParam = BodyParam(gene = JsonPatchDocumentGene("patch", schema), typeGene = typeGene) + + val action = getRestCallAction("/items/{id}", HttpVerb.PATCH, mutableListOf(bodyParam)) + val individual = RestIndividual(mutableListOf(action), SampleType.RANDOM) + TestUtils.doInitializeIndividualForTesting(individual, Randomness().apply { updateSeed(42L) }) + + val result = RestCallResult(action.getLocalId()).apply { setStatusCode(200) } + val ei = EvaluatedIndividual(FitnessValue(0.0), individual, listOf(result)) + val solution = Solution(singletonList(ei), "", "", Termination.NONE, emptyList(), emptyList()) + + val dtoWriter = DtoWriter(OutputFormat.KOTLIN_JUNIT_5) + dtoWriter.write(outputTestSuitePath, testPackage, solution) + val dtos = dtoWriter.getCollectedDtos() + + assertNotNull(dtos[GeneToDto.JSON_PATCH_OPERATION_DTO]) + + val patchGene = bodyParam.primaryGene() as JsonPatchDocumentGene + val pathValueOps = patchGene.operations.filterIsInstance() + assertTrue(pathValueOps.isNotEmpty(), "Seed 42L must produce at least one add/replace/test operation") + // All add/replace/test operations carry an ArrayGene value in this schema; + // the nested Tag DTO must therefore be collected. + assertNotNull(dtos["Tag"], "Nested Tag DTO must be collected for operations with array-of-objects value") + } }