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) + } + } +} 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();") }) + } +}