From 98ace446e29a425ab0cbb64262394683c846ef84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ROMINA=20JULIETA=20SU=C3=81REZ?= Date: Thu, 28 May 2026 00:13:23 -0300 Subject: [PATCH 1/5] Init approach --- .../bb/jsonpatch/BBJsonPatchApplication.kt | 45 +++ .../bb/jsonpatch/BBJsonPatchController.kt | 5 + .../rest/bb/jsonpatch/BBJsonPatchTest.kt | 41 +++ .../v3/jsonpatch/JsonPatchApplication.kt | 43 +++ .../v3/jsonpatch/JsonPatchController.kt | 5 + .../openapi/v3/jsonpatch/JsonPatchTest.kt | 34 +++ .../rest/builder/JsonPatchSchemaResolver.kt | 80 ++++++ .../rest/builder/RestActionBuilderV3.kt | 44 ++- .../builder/JsonPatchSchemaResolverTest.kt | 266 ++++++++++++++++++ 9 files changed, 553 insertions(+), 10 deletions(-) create mode 100644 core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/jsonpatch/BBJsonPatchApplication.kt create mode 100644 core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/com/foo/rest/examples/bb/jsonpatch/BBJsonPatchController.kt create mode 100644 core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/jsonpatch/BBJsonPatchTest.kt create mode 100644 core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchApplication.kt create mode 100644 core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchController.kt create mode 100644 core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/jsonpatch/JsonPatchTest.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt create mode 100644 core/src/test/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolverTest.kt diff --git a/core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/jsonpatch/BBJsonPatchApplication.kt b/core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/jsonpatch/BBJsonPatchApplication.kt new file mode 100644 index 0000000000..d0a7039f43 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/jsonpatch/BBJsonPatchApplication.kt @@ -0,0 +1,45 @@ +package com.foo.rest.examples.bb.jsonpatch + +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.evomaster.e2etests.utils.CoveredTargets +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) +@RestController +@RequestMapping("/pets") +open class BBJsonPatchApplication { + + companion object { + @JvmStatic + fun main(args: Array) { + SpringApplication.run(BBJsonPatchApplication::class.java, *args) + } + } + + private val store: MutableMap = mutableMapOf( + 1L to BBJsonPatchDto("Doggo", 3), + 2L to BBJsonPatchDto("Catto", 5) + ) + + @GetMapping("/{id}") + fun getPet(@PathVariable id: Long): ResponseEntity { + val pet = store[id] ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(pet) + } + + @PatchMapping("/{id}", consumes = ["application/json-patch+json"]) + fun patchPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity { + if (!store.containsKey(id)) return ResponseEntity.notFound().build() + val trimmed = body.trim() + if (!trimmed.startsWith("[")) { + return ResponseEntity.badRequest().body("Patch document must be a JSON array") + } + CoveredTargets.cover("PATCHED") + return ResponseEntity.ok("patched") + } +} + +data class BBJsonPatchDto(val name: String, val age: Int) diff --git a/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/com/foo/rest/examples/bb/jsonpatch/BBJsonPatchController.kt b/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/com/foo/rest/examples/bb/jsonpatch/BBJsonPatchController.kt new file mode 100644 index 0000000000..2a41003871 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/com/foo/rest/examples/bb/jsonpatch/BBJsonPatchController.kt @@ -0,0 +1,5 @@ +package com.foo.rest.examples.bb.jsonpatch + +import com.foo.rest.examples.bb.SpringController + +class BBJsonPatchController : SpringController(BBJsonPatchApplication::class.java) diff --git a/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/jsonpatch/BBJsonPatchTest.kt b/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/jsonpatch/BBJsonPatchTest.kt new file mode 100644 index 0000000000..557a0e00db --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/jsonpatch/BBJsonPatchTest.kt @@ -0,0 +1,41 @@ +package org.evomaster.e2etests.spring.rest.bb.jsonpatch + +import com.foo.rest.examples.bb.jsonpatch.BBJsonPatchController +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 + +class BBJsonPatchTest : SpringTestBase() { + + companion object { + @BeforeAll + @JvmStatic + fun init() { + val config = EMConfig() + initClass(BBJsonPatchController(), config) + } + } + + @ParameterizedTest + @EnumSource + fun testBlackBoxOutput(outputFormat: OutputFormat) { + executeAndEvaluateBBTest( + outputFormat, + "BBJsonPatchEM", + 200, + 3, + listOf("PATCHED") + ) { args: MutableList -> + + val solution = initAndRun(args) + + assertTrue(solution.individuals.size >= 1) + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}", "patched") + } + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchApplication.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchApplication.kt new file mode 100644 index 0000000000..810d08adb1 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchApplication.kt @@ -0,0 +1,43 @@ +package com.foo.rest.examples.spring.openapi.v3.jsonpatch + +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.* + +@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) +@RestController +@RequestMapping("/pets") +open class JsonPatchApplication { + + companion object { + @JvmStatic + fun main(args: Array) { + SpringApplication.run(JsonPatchApplication::class.java, *args) + } + } + + private val store: MutableMap = mutableMapOf( + 1L to JsonPatchDto("Doggo", 3), + 2L to JsonPatchDto("Catto", 5) + ) + + @GetMapping("/{id}") + fun getPet(@PathVariable id: Long): ResponseEntity { + val pet = store[id] ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(pet) + } + + @PatchMapping("/{id}", consumes = ["application/json-patch+json"]) + fun patchPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity { + if (!store.containsKey(id)) return ResponseEntity.notFound().build() + val trimmed = body.trim() + if (!trimmed.startsWith("[")) { + return ResponseEntity.badRequest().body("Patch document must be a JSON array") + } + return ResponseEntity.ok("patched") + } +} + +data class JsonPatchDto(val name: String, val age: Int) diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchController.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchController.kt new file mode 100644 index 0000000000..8155e9f338 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchController.kt @@ -0,0 +1,5 @@ +package com.foo.rest.examples.spring.openapi.v3.jsonpatch + +import com.foo.rest.examples.spring.openapi.v3.SpringController + +class JsonPatchController : SpringController(JsonPatchApplication::class.java) diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/jsonpatch/JsonPatchTest.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/jsonpatch/JsonPatchTest.kt new file mode 100644 index 0000000000..c7a0cfa2ee --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/jsonpatch/JsonPatchTest.kt @@ -0,0 +1,34 @@ +package org.evomaster.e2etests.spring.openapi.v3.jsonpatch + +import com.foo.rest.examples.spring.openapi.v3.jsonpatch.JsonPatchController +import org.evomaster.core.problem.rest.data.HttpVerb +import org.evomaster.e2etests.spring.openapi.v3.SpringTestBase +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test + +class JsonPatchTest : SpringTestBase() { + + companion object { + @BeforeAll + @JvmStatic + fun init() { + initClass(JsonPatchController()) + } + } + + @Test + fun testRunEM() { + runTestHandlingFlakyAndCompilation( + "JsonPatchEM", + "org.foo.JsonPatchEM", + 200 + ) { args: MutableList -> + + val solution = initAndRun(args) + + assertTrue(solution.individuals.size >= 1) + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}", "patched") + } + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt new file mode 100644 index 0000000000..2afd762c34 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt @@ -0,0 +1,80 @@ +package org.evomaster.core.problem.rest.builder + +import io.swagger.v3.oas.models.Operation +import io.swagger.v3.oas.models.PathItem +import io.swagger.v3.oas.models.media.MediaType +import io.swagger.v3.oas.models.media.Schema +import org.evomaster.core.problem.rest.schema.RestSchema +import org.evomaster.core.problem.rest.schema.SchemaOpenAPI +import org.evomaster.core.problem.rest.schema.SchemaUtils + +/** + * Resolves the target resource schema for a JSON Patch PATCH endpoint by inspecting + * sibling operations on the same path item. + * + * Priority: GET 2xx response → PUT requestBody → POST requestBody. + */ +object JsonPatchSchemaResolver { + + private const val JSON_PATCH_MEDIA_TYPE = "json-patch" + + fun resolveResourceSchema( + pathItem: PathItem, + schemaHolder: RestSchema, + currentSchema: SchemaOpenAPI, + messages: MutableList + ): Schema<*>? = + fromGetResponse(pathItem, schemaHolder, currentSchema, messages) + ?: fromRequestBody(pathItem.put, schemaHolder, currentSchema, messages) + ?: fromRequestBody(pathItem.post, schemaHolder, currentSchema, messages) + + private fun fromGetResponse( + pathItem: PathItem, + schemaHolder: RestSchema, + currentSchema: SchemaOpenAPI, + messages: MutableList + ): Schema<*>? { + val get = pathItem.get ?: return null + return get.responses + ?.filter { (code, _) -> code.startsWith("2") } + ?.values + ?.firstNotNullOfOrNull { response -> + val resolved = if (response.`$ref` != null) { + SchemaUtils.getReferenceResponse(schemaHolder, currentSchema, response.`$ref`, messages) + ?: return@firstNotNullOfOrNull null + } else response + extractJsonSchema(resolved.content, schemaHolder, currentSchema, messages) + } + } + + private fun fromRequestBody( + operation: Operation?, + schemaHolder: RestSchema, + currentSchema: SchemaOpenAPI, + messages: MutableList + ): Schema<*>? { + val body = operation?.requestBody ?: return null + val resolvedBody = if (body.`$ref` != null) { + SchemaUtils.getReferenceRequestBody(schemaHolder, currentSchema, body.`$ref`, messages) + ?: return null + } else body + return extractJsonSchema(resolvedBody.content, schemaHolder, currentSchema, messages) + } + + private fun extractJsonSchema( + content: Map?, + schemaHolder: RestSchema, + currentSchema: SchemaOpenAPI, + messages: MutableList + ): Schema<*>? { + val schema = content + ?.filterKeys { mt -> mt.contains("json") && !mt.contains(JSON_PATCH_MEDIA_TYPE) } + ?.values + ?.firstOrNull() + ?.schema + ?: return null + return if (schema.`$ref` != null) { + SchemaUtils.getReferenceSchema(schemaHolder, currentSchema, schema.`$ref`, messages) + } else schema + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/RestActionBuilderV3.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/RestActionBuilderV3.kt index 306d746182..e244de8e57 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/RestActionBuilderV3.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/RestActionBuilderV3.kt @@ -47,6 +47,7 @@ import org.evomaster.core.search.gene.wrapper.ChoiceGene import org.evomaster.core.search.gene.wrapper.CustomMutationRateGene import org.evomaster.core.search.gene.wrapper.OptionalGene import org.evomaster.core.search.gene.placeholder.CycleObjectGene +import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene import org.evomaster.core.search.gene.placeholder.LimitObjectGene import org.evomaster.core.search.gene.regex.RegexGene import org.evomaster.core.search.gene.string.Base64StringGene @@ -254,7 +255,16 @@ object RestActionBuilderV3 { if (pathItem.get != null) h(HttpVerb.GET, pathItem.get) if (pathItem.post != null) h(HttpVerb.POST, pathItem.post) if (pathItem.put != null) h(HttpVerb.PUT, pathItem.put) - if (pathItem.patch != null) h(HttpVerb.PATCH, pathItem.patch) + if (pathItem.patch != null) { + val patchSchema = JsonPatchSchemaResolver.resolveResourceSchema(pathItem, schemaHolder, schemaHolder.main, messages) + if (endpointsToSkip.any { it.verb == HttpVerb.PATCH && it.path.isEquivalent(rawPath) }) { + skipped.add(Endpoint(HttpVerb.PATCH, restPath)) + } else { + handleOperation(actionCluster, HttpVerb.PATCH, restPath, pathItem.patch, + schemaHolder, schemaHolder.main, options, errorEndpoints, messages, + patchResourceSchema = patchSchema) + } + } if (pathItem.options != null) h(HttpVerb.OPTIONS, pathItem.options) if (pathItem.delete != null) h(HttpVerb.DELETE, pathItem.delete) if (pathItem.trace != null) h(HttpVerb.TRACE, pathItem.trace) @@ -443,11 +453,12 @@ object RestActionBuilderV3 { currentSchema: SchemaOpenAPI, options: Options, errorEndpoints: MutableList, - messages: MutableList + messages: MutableList, + patchResourceSchema: Schema<*>? = null ) { try{ - val params = extractParams(verb, restPath, operation, schemaHolder,currentSchema, options, messages) + val params = extractParams(verb, restPath, operation, schemaHolder,currentSchema, options, messages, patchResourceSchema) repairParams(params, restPath, messages) val produces = operation.responses?.values //different response objects based on HTTP code @@ -525,7 +536,8 @@ object RestActionBuilderV3 { schemaHolder: RestSchema, currentSchema: SchemaOpenAPI, options: Options, - messages: MutableList + messages: MutableList, + patchResourceSchema: Schema<*>? = null ): MutableList { val params = mutableListOf() @@ -545,7 +557,7 @@ object RestActionBuilderV3 { } } - handleBodyPayload(operation, verb, restPath, schemaHolder, currentSchema, params, options, messages) + handleBodyPayload(operation, verb, restPath, schemaHolder, currentSchema, params, options, messages, patchResourceSchema) return params } @@ -676,7 +688,8 @@ object RestActionBuilderV3 { currentSchema: SchemaOpenAPI, params: MutableList, options: Options, - messages: MutableList + messages: MutableList, + patchResourceSchema: Schema<*>? = null ) { // Return early if requestBody is missing @@ -738,11 +751,22 @@ object RestActionBuilderV3 { listOf() } - // $ref schemas do not carry XML metadata; resolving the reference is required to obtain the correct XML element name from the target schema - val deref = obj.schema.`$ref`?.let { ref -> SchemaUtils.getReferenceSchema(schemaHolder, currentSchema, ref, messages) } ?: obj.schema - val name = deref?.xml?.name ?: deref?.`$ref`?.substringAfterLast("/") ?: "body" + val isJsonPatch = verb == HttpVerb.PATCH && bodies.keys.any { it.contains("json-patch") } - var gene = getGene(name, obj.schema, schemaHolder,currentSchema, referenceClassDef = null, options = options, messages = messages, examples = examples) + val name: String + var gene: Gene + if (isJsonPatch) { + name = "body" + val resourceGene = patchResourceSchema?.let { + getGene(name, it, schemaHolder, currentSchema, referenceClassDef = null, options = options, messages = messages) + } + gene = JsonPatchDocumentGene(name, resourceGene) + } else { + // $ref schemas do not carry XML metadata; resolving the reference is required to obtain the correct XML element name from the target schema + val deref = obj.schema.`$ref`?.let { ref -> SchemaUtils.getReferenceSchema(schemaHolder, currentSchema, ref, messages) } ?: obj.schema + name = deref?.xml?.name ?: deref?.`$ref`?.substringAfterLast("/") ?: "body" + gene = getGene(name, obj.schema, schemaHolder, currentSchema, referenceClassDef = null, options = options, messages = messages, examples = examples) + } if (resolvedBody.required != true && gene !is OptionalGene) { gene = OptionalGene(name, gene) diff --git a/core/src/test/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolverTest.kt b/core/src/test/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolverTest.kt new file mode 100644 index 0000000000..e6adf49bb0 --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolverTest.kt @@ -0,0 +1,266 @@ +package org.evomaster.core.problem.rest.builder + +import org.evomaster.core.problem.rest.schema.OpenApiAccess +import org.evomaster.core.problem.rest.schema.RestSchema +import org.evomaster.core.problem.rest.schema.SchemaLocation +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class JsonPatchSchemaResolverTest { + + private fun parse(json: String): RestSchema = + RestSchema(OpenApiAccess.parseOpenApi(json.trimIndent(), SchemaLocation.MEMORY)) + + private fun minimalSpec(pathsBlock: String) = """ + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { $pathsBlock } + } + """.trimIndent() + + @Test + fun testResolveFromGetResponse() { + val schema = parse(minimalSpec(""" + "/pets/{id}": { + "get": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"} + } + } + } + } + } + } + }, + "patch": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "requestBody": { + "required": true, + "content": {"application/json-patch+json": {"schema": {"type": "array", "items": {"type": "object"}}}} + }, + "responses": {"200": {"description": "ok"}} + } + } + """)) + + val pathItem = schema.main.schemaParsed.paths["/pets/{id}"]!! + val messages = mutableListOf() + + val result = JsonPatchSchemaResolver.resolveResourceSchema(pathItem, schema, schema.main, messages) + + assertNotNull(result) + assertTrue(messages.isEmpty(), "Unexpected messages: $messages") + val props = result!!.properties + assertNotNull(props) + assertTrue(props.containsKey("name"), "Expected property 'name'") + assertTrue(props.containsKey("age"), "Expected property 'age'") + } + + @Test + fun testPreferGetOverPut() { + val schema = parse(minimalSpec(""" + "/x/{id}": { + "get": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "responses": { + "200": { + "content": {"application/json": {"schema": {"type": "object", "properties": {"fromGet": {"type": "string"}}}}} + } + } + }, + "put": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "requestBody": { + "content": {"application/json": {"schema": {"type": "object", "properties": {"fromPut": {"type": "string"}}}}} + }, + "responses": {"200": {"description": "ok"}} + }, + "patch": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "requestBody": { + "content": {"application/json-patch+json": {"schema": {"type": "array", "items": {"type": "object"}}}} + }, + "responses": {"200": {"description": "ok"}} + } + } + """)) + + val pathItem = schema.main.schemaParsed.paths["/x/{id}"]!! + val result = JsonPatchSchemaResolver.resolveResourceSchema(pathItem, schema, schema.main, mutableListOf()) + + assertNotNull(result) + val props = result!!.properties + assertTrue(props.containsKey("fromGet"), "Should prefer GET schema, got $props") + assertFalse(props.containsKey("fromPut")) + } + + @Test + fun testFallbackToPut() { + val schema = parse(minimalSpec(""" + "/orders/{id}": { + "put": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "requestBody": { + "content": {"application/json": {"schema": {"type": "object", "properties": {"product": {"type": "string"}, "quantity": {"type": "integer"}}}}} + }, + "responses": {"200": {"description": "ok"}} + }, + "patch": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "requestBody": { + "content": {"application/json-patch+json": {"schema": {"type": "array", "items": {"type": "object"}}}} + }, + "responses": {"200": {"description": "ok"}} + } + } + """)) + + val pathItem = schema.main.schemaParsed.paths["/orders/{id}"]!! + val result = JsonPatchSchemaResolver.resolveResourceSchema(pathItem, schema, schema.main, mutableListOf()) + + assertNotNull(result) + val props = result!!.properties + assertTrue(props.containsKey("product"), "Expected 'product' in $props") + assertTrue(props.containsKey("quantity"), "Expected 'quantity' in $props") + } + + @Test + fun testFallbackToPost() { + val schema = parse(minimalSpec(""" + "/users": { + "post": { + "requestBody": { + "content": {"application/json": {"schema": {"type": "object", "properties": {"email": {"type": "string"}}}}} + }, + "responses": {"200": {"description": "ok"}} + }, + "patch": { + "requestBody": { + "content": {"application/json-patch+json": {"schema": {"type": "array", "items": {"type": "object"}}}} + }, + "responses": {"200": {"description": "ok"}} + } + } + """)) + + val pathItem = schema.main.schemaParsed.paths["/users"]!! + val result = JsonPatchSchemaResolver.resolveResourceSchema(pathItem, schema, schema.main, mutableListOf()) + + assertNotNull(result) + assertTrue(result!!.properties.containsKey("email")) + } + + @Test + fun testReturnsNullWhenNoSiblings() { + val schema = parse(minimalSpec(""" + "/items/{id}": { + "patch": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "requestBody": { + "content": {"application/json-patch+json": {"schema": {"type": "array", "items": {"type": "object"}}}} + }, + "responses": {"200": {"description": "ok"}} + } + } + """)) + + val pathItem = schema.main.schemaParsed.paths["/items/{id}"]!! + val result = JsonPatchSchemaResolver.resolveResourceSchema(pathItem, schema, schema.main, mutableListOf()) + + assertNull(result, "Expected null when no sibling operations define a JSON schema") + } + + @Test + fun testIgnoresJsonPatchContentTypeInGetResponse() { + val schema = parse(minimalSpec(""" + "/docs/{id}": { + "get": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "responses": { + "200": { + "content": { + "application/json-patch+json": { + "schema": {"type": "array", "items": {"type": "object"}} + } + } + } + } + }, + "patch": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "requestBody": { + "content": {"application/json-patch+json": {"schema": {"type": "array", "items": {"type": "object"}}}} + }, + "responses": {"200": {"description": "ok"}} + } + } + """)) + + val pathItem = schema.main.schemaParsed.paths["/docs/{id}"]!! + val result = JsonPatchSchemaResolver.resolveResourceSchema(pathItem, schema, schema.main, mutableListOf()) + + assertNull(result, "Should not use json-patch content type as resource schema") + } + + @Test + fun testResolveFromGetResponseViaRef() { + val schema = parse(""" + { + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/cats/{id}": { + "get": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {"${'$'}ref": "#/components/schemas/Cat"} + } + } + } + } + }, + "patch": { + "parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}], + "requestBody": { + "content": {"application/json-patch+json": {"schema": {"type": "array", "items": {"type": "object"}}}} + }, + "responses": {"200": {"description": "ok"}} + } + } + }, + "components": { + "schemas": { + "Cat": { + "type": "object", + "properties": { + "breed": {"type": "string"}, + "indoor": {"type": "boolean"} + } + } + } + } + } + """.trimIndent()) + + val pathItem = schema.main.schemaParsed.paths["/cats/{id}"]!! + val result = JsonPatchSchemaResolver.resolveResourceSchema(pathItem, schema, schema.main, mutableListOf()) + + assertNotNull(result) + val props = result!!.properties + assertTrue(props.containsKey("breed"), "Expected 'breed' via \$ref resolution, got $props") + assertTrue(props.containsKey("indoor"), "Expected 'indoor' via \$ref resolution, got $props") + } +} From 9fdf0a60fbd403421c74f98e0ff8a781fd8d03ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ROMINA=20JULIETA=20SU=C3=81REZ?= Date: Wed, 3 Jun 2026 21:07:06 -0300 Subject: [PATCH 2/5] Fix DTOtest, and add betters to e2e --- .../bb/jsonpatch/BBJsonPatchApplication.kt | 113 +++++++++++++- .../rest/bb/jsonpatch/BBJsonPatchTest.kt | 37 ++++- .../v3/jsonpatch/JsonPatchApplication.kt | 140 +++++++++++++++++- .../openapi/v3/jsonpatch/JsonPatchTest.kt | 39 ++++- .../evomaster/core/output/dto/DtoWriter.kt | 2 + .../core/output/dto/DtoWriterTest.kt | 20 +++ 6 files changed, 325 insertions(+), 26 deletions(-) diff --git a/core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/jsonpatch/BBJsonPatchApplication.kt b/core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/jsonpatch/BBJsonPatchApplication.kt index d0a7039f43..a859ce58e8 100644 --- a/core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/jsonpatch/BBJsonPatchApplication.kt +++ b/core-tests/e2e-tests/spring/spring-rest-bb/src/main/kotlin/com/foo/rest/examples/bb/jsonpatch/BBJsonPatchApplication.kt @@ -1,5 +1,9 @@ package com.foo.rest.examples.bb.jsonpatch +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses import org.springframework.boot.SpringApplication import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration @@ -20,26 +24,119 @@ open class BBJsonPatchApplication { } private val store: MutableMap = mutableMapOf( - 1L to BBJsonPatchDto("Doggo", 3), - 2L to BBJsonPatchDto("Catto", 5) + 1L to BBJsonPatchDto("Dog", 3), + 2L to BBJsonPatchDto("Cat", 5) ) + private val mapper = ObjectMapper() + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Pet found"), + ApiResponse(responseCode = "400", description = "Invalid pet id") + ]) @GetMapping("/{id}") fun getPet(@PathVariable id: Long): ResponseEntity { - val pet = store[id] ?: return ResponseEntity.notFound().build() + val pet = store[id] ?: return ResponseEntity.badRequest().build() return ResponseEntity.ok(pet) } + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Patch document processed successfully"), + ApiResponse(responseCode = "400", description = "Patch document is not a JSON array") + ]) @PatchMapping("/{id}", consumes = ["application/json-patch+json"]) fun patchPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity { - if (!store.containsKey(id)) return ResponseEntity.notFound().build() - val trimmed = body.trim() - if (!trimmed.startsWith("[")) { + if (parsePatchDocument(body) == null) return ResponseEntity.badRequest().body("Patch document must be a JSON array") - } + CoveredTargets.cover("PATCHED") return ResponseEntity.ok("patched") } + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Add operation processed successfully"), + ApiResponse(responseCode = "400", description = "Patch document does not contain an add operation") + ]) + @PatchMapping("/{id}/add", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun addPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(body, "add", "JSON_PATCH_ADD") + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Remove operation processed successfully"), + ApiResponse(responseCode = "400", description = "Patch document does not contain a remove operation") + ]) + @PatchMapping("/{id}/remove", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun removePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(body, "remove", "JSON_PATCH_REMOVE") + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Replace operation processed successfully"), + ApiResponse(responseCode = "400", description = "Patch document does not contain a replace operation") + ]) + @PatchMapping("/{id}/replace", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun replacePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(body, "replace", "JSON_PATCH_REPLACE") + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Move operation processed successfully"), + ApiResponse(responseCode = "400", description = "Patch document does not contain a move operation") + ]) + @PatchMapping("/{id}/move", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun movePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(body, "move", "JSON_PATCH_MOVE") + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Copy operation processed successfully"), + ApiResponse(responseCode = "400", description = "Patch document does not contain a copy operation") + ]) + @PatchMapping("/{id}/copy", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun copyPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(body, "copy", "JSON_PATCH_COPY") + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Test operation processed successfully"), + ApiResponse(responseCode = "400", description = "Patch document does not contain a test operation") + ]) + @PatchMapping("/{id}/test", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun testPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(body, "test", "JSON_PATCH_TEST") + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Patch document has multiple operations"), + ApiResponse(responseCode = "400", description = "Patch document has fewer than two operations") + ]) + @PatchMapping("/{id}/sequence", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun sequencePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity { + if (!hasMultipleOperations(body)) + return ResponseEntity.badRequest().body("Patch document must contain at least two operations") + + CoveredTargets.cover("JSON_PATCH_SEQUENCE") + return ResponseEntity.ok("sequence patched") + } + + private fun patchOperation(body: String, operation: String, target: String): ResponseEntity { + if (!hasOperation(body, operation)) + return ResponseEntity.badRequest().body("Patch document must contain a $operation operation") + + CoveredTargets.cover(target) + return ResponseEntity.ok("$operation patched") + } + + private fun hasOperation(body: String, operation: String): Boolean = + parsePatchDocument(body)?.any { it.path("op").asText() == operation } ?: false + + private fun hasMultipleOperations(body: String): Boolean = + parsePatchDocument(body) + ?.takeIf { it.size() >= 2 } + ?.all { it.hasNonNull("op") } + ?: false + + private fun parsePatchDocument(body: String): JsonNode? = + try { + mapper.readTree(body).takeIf { it.isArray } + } catch (e: Exception) { + null + } } -data class BBJsonPatchDto(val name: String, val age: Int) +data class BBJsonPatchDto(val name: String = "", val age: Int = 0) \ No newline at end of file diff --git a/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/jsonpatch/BBJsonPatchTest.kt b/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/jsonpatch/BBJsonPatchTest.kt index 557a0e00db..f4d5879149 100644 --- a/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/jsonpatch/BBJsonPatchTest.kt +++ b/core-tests/e2e-tests/spring/spring-rest-bb/src/test/kotlin/org/evomaster/e2etests/spring/rest/bb/jsonpatch/BBJsonPatchTest.kt @@ -27,15 +27,46 @@ class BBJsonPatchTest : SpringTestBase() { executeAndEvaluateBBTest( outputFormat, "BBJsonPatchEM", - 200, + 1000, 3, - listOf("PATCHED") + listOf( + "PATCHED", + "JSON_PATCH_ADD", + "JSON_PATCH_REMOVE", + "JSON_PATCH_REPLACE", + "JSON_PATCH_MOVE", + "JSON_PATCH_COPY", + "JSON_PATCH_TEST", + "JSON_PATCH_SEQUENCE" + ) ) { args: MutableList -> val solution = initAndRun(args) assertTrue(solution.individuals.size >= 1) + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}", "patched") + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/add", "add patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/add", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/remove", "remove patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/remove", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/replace", "replace patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/replace", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/move", "move patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/move", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/copy", "copy patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/copy", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/test", "test patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/test", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/sequence", "sequence patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/sequence", null) } } -} +} \ No newline at end of file diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchApplication.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchApplication.kt index 810d08adb1..0527407848 100644 --- a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchApplication.kt +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchApplication.kt @@ -1,5 +1,9 @@ package com.foo.rest.examples.spring.openapi.v3.jsonpatch +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses import org.springframework.boot.SpringApplication import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration @@ -19,25 +23,145 @@ open class JsonPatchApplication { } private val store: MutableMap = mutableMapOf( - 1L to JsonPatchDto("Doggo", 3), - 2L to JsonPatchDto("Catto", 5) + 1L to JsonPatchDto("Dog", 3), + 2L to JsonPatchDto("Cat", 5) ) + private val mapper = ObjectMapper() + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Pet found"), + ApiResponse(responseCode = "400", description = "Invalid pet id") + ]) @GetMapping("/{id}") fun getPet(@PathVariable id: Long): ResponseEntity { - val pet = store[id] ?: return ResponseEntity.notFound().build() + val pet = store[id] ?: return ResponseEntity.badRequest().build() return ResponseEntity.ok(pet) } + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Patch document processed successfully"), + ApiResponse(responseCode = "400", description = "Pet id is invalid or patch document is not a JSON array") + ]) @PatchMapping("/{id}", consumes = ["application/json-patch+json"]) fun patchPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity { - if (!store.containsKey(id)) return ResponseEntity.notFound().build() - val trimmed = body.trim() - if (!trimmed.startsWith("[")) { + if (!store.containsKey(id)) return ResponseEntity.badRequest().body("Invalid pet id") + + if (parsePatchDocument(body) == null) return ResponseEntity.badRequest().body("Patch document must be a JSON array") - } + return ResponseEntity.ok("patched") } + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Add operation processed successfully"), + ApiResponse(responseCode = "400", description = "Pet id is not positive or patch document does not contain an add operation for /a") + ]) + @PatchMapping("/{id}/add", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun addPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(id, body, "add") { it.path("path").asText() == "/a" } + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Remove operation processed successfully"), + ApiResponse(responseCode = "400", description = "Pet id is not positive or patch document does not contain a remove operation for /b") + ]) + @PatchMapping("/{id}/remove", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun removePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(id, body, "remove") { it.path("path").asText() == "/b" } + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Replace operation processed successfully"), + ApiResponse(responseCode = "400", description = "Pet id is not positive or patch document does not contain a replace operation for /c") + ]) + @PatchMapping("/{id}/replace", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun replacePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(id, body, "replace") { it.path("path").asText() == "/c" } + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Move operation processed successfully"), + ApiResponse(responseCode = "400", description = "Pet id is not positive or patch document does not contain a move operation from /a to /d") + ]) + @PatchMapping("/{id}/move", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun movePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(id, body, "move") { + it.path("from").asText() == "/a" && it.path("path").asText() == "/d" + } + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Copy operation processed successfully"), + ApiResponse(responseCode = "400", description = "Pet id is not positive or patch document does not contain a copy operation from /a to /d") + ]) + @PatchMapping("/{id}/copy", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun copyPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(id, body, "copy") { + it.path("from").asText() == "/a" && it.path("path").asText() == "/d" + } + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Test operation processed successfully"), + ApiResponse(responseCode = "400", description = "Pet id is not positive or patch document does not contain a test operation for /b") + ]) + @PatchMapping("/{id}/test", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun testPet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity = + patchOperation(id, body, "test") { it.path("path").asText() == "/b" } + + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Patch document has multiple operations"), + ApiResponse(responseCode = "400", description = "Pet id is not positive or patch document does not add /a before replacing /c") + ]) + @PatchMapping("/{id}/sequence", consumes = ["application/json-patch+json"], produces = ["text/plain"]) + fun sequencePet(@PathVariable id: Long, @RequestBody body: String): ResponseEntity { + if (id <= 0) + return ResponseEntity.badRequest().body("Pet id must be positive") + + if (!hasAddThenReplaceSequence(body)) + return ResponseEntity.badRequest().body("Patch document must contain add followed by replace") + + return ResponseEntity.ok("sequence patched") + } + + private fun patchOperation( + id: Long, + body: String, + operation: String, + extraCheck: (JsonNode) -> Boolean + ): ResponseEntity { + if (id <= 0) + return ResponseEntity.badRequest().body("Pet id must be positive") + + if (!hasOperation(body, operation, extraCheck)) + return ResponseEntity.badRequest().body("Patch document must contain a $operation operation") + + return ResponseEntity.ok("$operation patched") + } + + private fun hasOperation(body: String, operation: String, extraCheck: (JsonNode) -> Boolean): Boolean = + parsePatchDocument(body)?.any { it.path("op").asText() == operation && extraCheck(it) } ?: false + + private fun hasAddThenReplaceSequence(body: String): Boolean { + val operations = parsePatchDocument(body) ?: return false + if (operations.size() < 2) + return false + + var hasAdd = false + var hasReplace = false + + for (operation in operations) { + if (operation.path("op").asText() == "add" && operation.path("path").asText() == "/a") + hasAdd = true + if (operation.path("op").asText() == "replace" && operation.path("path").asText() == "/c") + hasReplace = true + } + + return hasAdd && hasReplace + } + + private fun parsePatchDocument(body: String): JsonNode? = + try { + mapper.readTree(body).takeIf { it.isArray } + } catch (e: Exception) { + null + } } -data class JsonPatchDto(val name: String, val age: Int) +data class JsonPatchDto(val name: String = "", val age: Int = 0) \ No newline at end of file diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/jsonpatch/JsonPatchTest.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/jsonpatch/JsonPatchTest.kt index c7a0cfa2ee..c8346c61cc 100644 --- a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/jsonpatch/JsonPatchTest.kt +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/jsonpatch/JsonPatchTest.kt @@ -22,13 +22,38 @@ class JsonPatchTest : SpringTestBase() { runTestHandlingFlakyAndCompilation( "JsonPatchEM", "org.foo.JsonPatchEM", - 200 - ) { args: MutableList -> + 2000, + true, + { args: MutableList -> - val solution = initAndRun(args) + val solution = initAndRun(args) - assertTrue(solution.individuals.size >= 1) - assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}", "patched") - } + assertTrue(solution.individuals.size >= 1) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}", "patched") + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/add", "add patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/add", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/remove", "remove patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/remove", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/replace", "replace patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/replace", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/move", "move patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/move", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/copy", "copy patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/copy", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/test", "test patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/test", null) + + assertHasAtLeastOne(solution, HttpVerb.PATCH, 200, "/pets/{id}/sequence", "sequence patched") + assertHasAtLeastOne(solution, HttpVerb.PATCH, 400, "/pets/{id}/sequence", null) + }, + 3, + ) } -} +} \ No newline at end of file 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 64d229d788..590ca5ccdd 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 @@ -22,6 +22,7 @@ import org.evomaster.core.search.gene.numeric.FloatGene 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.regex.RegexGene import org.evomaster.core.search.gene.string.Base64StringGene import org.evomaster.core.search.gene.string.StringGene @@ -118,6 +119,7 @@ class DtoWriter( gene is ObjectGene -> calculateDtoFromObject(gene, actionName) gene is ArrayGene<*> -> calculateDtoFromArray(gene, actionName) gene is FixedMapGene<*, *> -> calculateDtoFromFixedMapGene(gene, actionName) + gene is JsonPatchDocumentGene -> return isPrimitiveGene(gene) -> return else -> { throw IllegalStateException("Gene $gene is not supported for DTO payloads for action: $actionName") diff --git a/core/src/test/kotlin/org/evomaster/core/output/dto/DtoWriterTest.kt b/core/src/test/kotlin/org/evomaster/core/output/dto/DtoWriterTest.kt index f722178a4c..ecf446db49 100644 --- a/core/src/test/kotlin/org/evomaster/core/output/dto/DtoWriterTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/output/dto/DtoWriterTest.kt @@ -9,10 +9,13 @@ import org.evomaster.core.output.naming.RestActionTestCaseUtils.getRestCallActio import org.evomaster.core.problem.rest.builder.RestActionBuilderV3 import org.evomaster.core.problem.rest.data.HttpVerb import org.evomaster.core.problem.rest.data.RestIndividual +import org.evomaster.core.problem.rest.param.BodyParam import org.evomaster.core.problem.rest.schema.OpenApiAccess import org.evomaster.core.problem.rest.schema.RestSchema import org.evomaster.core.search.Solution import org.evomaster.core.search.action.Action +import org.evomaster.core.search.gene.collection.EnumGene +import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.hasItem import org.hamcrest.Matchers.`is` @@ -95,6 +98,23 @@ class DtoWriterTest { assertEquals(dtoWriter.getCollectedDtos().size, 0) } + @Test + fun jsonPatchPayloadsAreNotCollectedAsDtos() { + val dtoWriter = DtoWriter(outputFormat) + val bodyParam = BodyParam( + gene = JsonPatchDocumentGene("patch"), + typeGene = EnumGene("contentType", listOf("application/json-patch+json")).apply { index = 0 } + ) + val eIndividual = getEvaluatedIndividualWith( + getRestCallAction("/pets/{id}", HttpVerb.PATCH, mutableListOf(bodyParam)) + ) + val solution = Solution(singletonList(eIndividual), "", "", Termination.NONE, emptyList(), emptyList()) + + dtoWriter.write(outputTestSuitePath, TEST_PACKAGE, solution) + + assertTrue(dtoWriter.getCollectedDtos().isEmpty()) + } + // TODO: Migrate tests to integration tests using reflection to assert correct DTO generation @Disabled("Tests disabled until migrated to integration tests") @Test From 78c7fed538f72416807a51c872a69c8d075717ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ROMINA=20JULIETA=20SU=C3=81REZ?= Date: Wed, 3 Jun 2026 23:25:14 -0300 Subject: [PATCH 3/5] Fix overhead of changes --- .../rest/builder/JsonPatchSchemaResolver.kt | 46 +++++++++++++++++++ .../rest/builder/RestActionBuilderV3.kt | 31 ++++++------- 2 files changed, 59 insertions(+), 18 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt index 2afd762c34..86cf4309ed 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt @@ -4,6 +4,7 @@ import io.swagger.v3.oas.models.Operation import io.swagger.v3.oas.models.PathItem import io.swagger.v3.oas.models.media.MediaType import io.swagger.v3.oas.models.media.Schema +import org.evomaster.core.problem.rest.data.HttpVerb import org.evomaster.core.problem.rest.schema.RestSchema import org.evomaster.core.problem.rest.schema.SchemaOpenAPI import org.evomaster.core.problem.rest.schema.SchemaUtils @@ -28,6 +29,51 @@ object JsonPatchSchemaResolver { ?: fromRequestBody(pathItem.put, schemaHolder, currentSchema, messages) ?: fromRequestBody(pathItem.post, schemaHolder, currentSchema, messages) + fun resolveResourceSchema( + operation: Operation, + verb: HttpVerb, + schemaHolder: RestSchema, + currentSchema: SchemaOpenAPI, + messages: MutableList + ): Schema<*>? { + val pathItem = findPathItemForOperation(operation, verb, schemaHolder, currentSchema, messages) + ?: return null + + return resolveResourceSchema(pathItem, schemaHolder, currentSchema, messages) + } + + private fun findPathItemForOperation( + operation: Operation, + verb: HttpVerb, + schemaHolder: RestSchema, + currentSchema: SchemaOpenAPI, + messages: MutableList + ): PathItem? { + return schemaHolder.main.schemaParsed.paths + ?.values + ?.firstNotNullOfOrNull { pathItemOrRef -> + val pathItem = if (pathItemOrRef.`$ref` != null) { + SchemaUtils.getReferencePathItem(schemaHolder, currentSchema, pathItemOrRef.`$ref`, messages) + } else { + pathItemOrRef + } + + pathItem?.takeIf { getOperation(it, verb) === operation } + } + } + + private fun getOperation(pathItem: PathItem, verb: HttpVerb): Operation? = + when (verb) { + HttpVerb.GET -> pathItem.get + HttpVerb.POST -> pathItem.post + HttpVerb.PUT -> pathItem.put + HttpVerb.DELETE -> pathItem.delete + HttpVerb.OPTIONS -> pathItem.options + HttpVerb.PATCH -> pathItem.patch + HttpVerb.TRACE -> pathItem.trace + HttpVerb.HEAD -> pathItem.head + } + private fun fromGetResponse( pathItem: PathItem, schemaHolder: RestSchema, diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/RestActionBuilderV3.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/RestActionBuilderV3.kt index e244de8e57..c4384db9b8 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/RestActionBuilderV3.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/RestActionBuilderV3.kt @@ -255,16 +255,7 @@ object RestActionBuilderV3 { if (pathItem.get != null) h(HttpVerb.GET, pathItem.get) if (pathItem.post != null) h(HttpVerb.POST, pathItem.post) if (pathItem.put != null) h(HttpVerb.PUT, pathItem.put) - if (pathItem.patch != null) { - val patchSchema = JsonPatchSchemaResolver.resolveResourceSchema(pathItem, schemaHolder, schemaHolder.main, messages) - if (endpointsToSkip.any { it.verb == HttpVerb.PATCH && it.path.isEquivalent(rawPath) }) { - skipped.add(Endpoint(HttpVerb.PATCH, restPath)) - } else { - handleOperation(actionCluster, HttpVerb.PATCH, restPath, pathItem.patch, - schemaHolder, schemaHolder.main, options, errorEndpoints, messages, - patchResourceSchema = patchSchema) - } - } + if (pathItem.patch != null) h(HttpVerb.PATCH, pathItem.patch) if (pathItem.options != null) h(HttpVerb.OPTIONS, pathItem.options) if (pathItem.delete != null) h(HttpVerb.DELETE, pathItem.delete) if (pathItem.trace != null) h(HttpVerb.TRACE, pathItem.trace) @@ -453,12 +444,11 @@ object RestActionBuilderV3 { currentSchema: SchemaOpenAPI, options: Options, errorEndpoints: MutableList, - messages: MutableList, - patchResourceSchema: Schema<*>? = null + messages: MutableList ) { try{ - val params = extractParams(verb, restPath, operation, schemaHolder,currentSchema, options, messages, patchResourceSchema) + val params = extractParams(verb, restPath, operation, schemaHolder,currentSchema, options, messages) repairParams(params, restPath, messages) val produces = operation.responses?.values //different response objects based on HTTP code @@ -536,8 +526,7 @@ object RestActionBuilderV3 { schemaHolder: RestSchema, currentSchema: SchemaOpenAPI, options: Options, - messages: MutableList, - patchResourceSchema: Schema<*>? = null + messages: MutableList ): MutableList { val params = mutableListOf() @@ -557,7 +546,7 @@ object RestActionBuilderV3 { } } - handleBodyPayload(operation, verb, restPath, schemaHolder, currentSchema, params, options, messages, patchResourceSchema) + handleBodyPayload(operation, verb, restPath, schemaHolder, currentSchema, params, options, messages) return params } @@ -688,8 +677,7 @@ object RestActionBuilderV3 { currentSchema: SchemaOpenAPI, params: MutableList, options: Options, - messages: MutableList, - patchResourceSchema: Schema<*>? = null + messages: MutableList ) { // Return early if requestBody is missing @@ -757,6 +745,13 @@ object RestActionBuilderV3 { var gene: Gene if (isJsonPatch) { name = "body" + val patchResourceSchema = JsonPatchSchemaResolver.resolveResourceSchema( + operation, + verb, + schemaHolder, + currentSchema, + messages + ) val resourceGene = patchResourceSchema?.let { getGene(name, it, schemaHolder, currentSchema, referenceClassDef = null, options = options, messages = messages) } From e9176b83596db667899b177ec25d826a86c839ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ROMINA=20JULIETA=20SU=C3=81REZ?= Date: Wed, 3 Jun 2026 23:43:44 -0300 Subject: [PATCH 4/5] fix extra --- .../rest/builder/JsonPatchSchemaResolver.kt | 21 +++---------------- .../rest/builder/RestActionBuilderV3.kt | 1 - 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt index 86cf4309ed..2da5b896df 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt @@ -4,7 +4,6 @@ import io.swagger.v3.oas.models.Operation import io.swagger.v3.oas.models.PathItem import io.swagger.v3.oas.models.media.MediaType import io.swagger.v3.oas.models.media.Schema -import org.evomaster.core.problem.rest.data.HttpVerb import org.evomaster.core.problem.rest.schema.RestSchema import org.evomaster.core.problem.rest.schema.SchemaOpenAPI import org.evomaster.core.problem.rest.schema.SchemaUtils @@ -31,20 +30,18 @@ object JsonPatchSchemaResolver { fun resolveResourceSchema( operation: Operation, - verb: HttpVerb, schemaHolder: RestSchema, currentSchema: SchemaOpenAPI, messages: MutableList ): Schema<*>? { - val pathItem = findPathItemForOperation(operation, verb, schemaHolder, currentSchema, messages) + val pathItem = findPathItemForPatchOperation(operation, schemaHolder, currentSchema, messages) ?: return null return resolveResourceSchema(pathItem, schemaHolder, currentSchema, messages) } - private fun findPathItemForOperation( + private fun findPathItemForPatchOperation( operation: Operation, - verb: HttpVerb, schemaHolder: RestSchema, currentSchema: SchemaOpenAPI, messages: MutableList @@ -58,22 +55,10 @@ object JsonPatchSchemaResolver { pathItemOrRef } - pathItem?.takeIf { getOperation(it, verb) === operation } + pathItem?.takeIf { it.patch === operation } } } - private fun getOperation(pathItem: PathItem, verb: HttpVerb): Operation? = - when (verb) { - HttpVerb.GET -> pathItem.get - HttpVerb.POST -> pathItem.post - HttpVerb.PUT -> pathItem.put - HttpVerb.DELETE -> pathItem.delete - HttpVerb.OPTIONS -> pathItem.options - HttpVerb.PATCH -> pathItem.patch - HttpVerb.TRACE -> pathItem.trace - HttpVerb.HEAD -> pathItem.head - } - private fun fromGetResponse( pathItem: PathItem, schemaHolder: RestSchema, diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/RestActionBuilderV3.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/RestActionBuilderV3.kt index c4384db9b8..c34cc5dc96 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/RestActionBuilderV3.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/RestActionBuilderV3.kt @@ -747,7 +747,6 @@ object RestActionBuilderV3 { name = "body" val patchResourceSchema = JsonPatchSchemaResolver.resolveResourceSchema( operation, - verb, schemaHolder, currentSchema, messages From b162b837b57190f008558c4301a68dd658d4df04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ROMINA=20JULIETA=20SU=C3=81REZ?= Date: Wed, 3 Jun 2026 23:58:05 -0300 Subject: [PATCH 5/5] tunning --- .../rest/builder/JsonPatchSchemaResolver.kt | 19 ++++------- .../builder/JsonPatchSchemaResolverTest.kt | 32 +++++++++++-------- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt index 2da5b896df..af2fc95573 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolver.kt @@ -9,7 +9,7 @@ import org.evomaster.core.problem.rest.schema.SchemaOpenAPI import org.evomaster.core.problem.rest.schema.SchemaUtils /** - * Resolves the target resource schema for a JSON Patch PATCH endpoint by inspecting + * Resolves the target resource schema for a JSON Patch endpoint by inspecting * sibling operations on the same path item. * * Priority: GET 2xx response → PUT requestBody → POST requestBody. @@ -18,16 +18,6 @@ object JsonPatchSchemaResolver { private const val JSON_PATCH_MEDIA_TYPE = "json-patch" - fun resolveResourceSchema( - pathItem: PathItem, - schemaHolder: RestSchema, - currentSchema: SchemaOpenAPI, - messages: MutableList - ): Schema<*>? = - fromGetResponse(pathItem, schemaHolder, currentSchema, messages) - ?: fromRequestBody(pathItem.put, schemaHolder, currentSchema, messages) - ?: fromRequestBody(pathItem.post, schemaHolder, currentSchema, messages) - fun resolveResourceSchema( operation: Operation, schemaHolder: RestSchema, @@ -37,9 +27,12 @@ object JsonPatchSchemaResolver { val pathItem = findPathItemForPatchOperation(operation, schemaHolder, currentSchema, messages) ?: return null - return resolveResourceSchema(pathItem, schemaHolder, currentSchema, messages) + return fromGetResponse(pathItem, schemaHolder, currentSchema, messages) + ?: fromRequestBody(pathItem.put, schemaHolder, currentSchema, messages) + ?: fromRequestBody(pathItem.post, schemaHolder, currentSchema, messages) } + //handleBodyPayload does not have the path of the operation, we need to find it, only if it is patch private fun findPathItemForPatchOperation( operation: Operation, schemaHolder: RestSchema, @@ -59,6 +52,7 @@ object JsonPatchSchemaResolver { } } + // For get, the resource is in the response private fun fromGetResponse( pathItem: PathItem, schemaHolder: RestSchema, @@ -78,6 +72,7 @@ object JsonPatchSchemaResolver { } } + // For put and post, the resource is in the requestBody private fun fromRequestBody( operation: Operation?, schemaHolder: RestSchema, diff --git a/core/src/test/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolverTest.kt b/core/src/test/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolverTest.kt index e6adf49bb0..def0cc541b 100644 --- a/core/src/test/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolverTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/problem/rest/builder/JsonPatchSchemaResolverTest.kt @@ -19,6 +19,17 @@ class JsonPatchSchemaResolverTest { } """.trimIndent() + private fun resolveForPatch( + schema: RestSchema, + path: String, + messages: MutableList = mutableListOf() + ) = JsonPatchSchemaResolver.resolveResourceSchema( + schema.main.schemaParsed.paths[path]!!.patch, + schema, + schema.main, + messages + ) + @Test fun testResolveFromGetResponse() { val schema = parse(minimalSpec(""" @@ -53,10 +64,9 @@ class JsonPatchSchemaResolverTest { } """)) - val pathItem = schema.main.schemaParsed.paths["/pets/{id}"]!! val messages = mutableListOf() - val result = JsonPatchSchemaResolver.resolveResourceSchema(pathItem, schema, schema.main, messages) + val result = resolveForPatch(schema, "/pets/{id}", messages) assertNotNull(result) assertTrue(messages.isEmpty(), "Unexpected messages: $messages") @@ -95,8 +105,7 @@ class JsonPatchSchemaResolverTest { } """)) - val pathItem = schema.main.schemaParsed.paths["/x/{id}"]!! - val result = JsonPatchSchemaResolver.resolveResourceSchema(pathItem, schema, schema.main, mutableListOf()) + val result = resolveForPatch(schema, "/x/{id}") assertNotNull(result) val props = result!!.properties @@ -125,8 +134,7 @@ class JsonPatchSchemaResolverTest { } """)) - val pathItem = schema.main.schemaParsed.paths["/orders/{id}"]!! - val result = JsonPatchSchemaResolver.resolveResourceSchema(pathItem, schema, schema.main, mutableListOf()) + val result = resolveForPatch(schema, "/orders/{id}") assertNotNull(result) val props = result!!.properties @@ -153,8 +161,7 @@ class JsonPatchSchemaResolverTest { } """)) - val pathItem = schema.main.schemaParsed.paths["/users"]!! - val result = JsonPatchSchemaResolver.resolveResourceSchema(pathItem, schema, schema.main, mutableListOf()) + val result = resolveForPatch(schema, "/users") assertNotNull(result) assertTrue(result!!.properties.containsKey("email")) @@ -174,8 +181,7 @@ class JsonPatchSchemaResolverTest { } """)) - val pathItem = schema.main.schemaParsed.paths["/items/{id}"]!! - val result = JsonPatchSchemaResolver.resolveResourceSchema(pathItem, schema, schema.main, mutableListOf()) + val result = resolveForPatch(schema, "/items/{id}") assertNull(result, "Expected null when no sibling operations define a JSON schema") } @@ -206,8 +212,7 @@ class JsonPatchSchemaResolverTest { } """)) - val pathItem = schema.main.schemaParsed.paths["/docs/{id}"]!! - val result = JsonPatchSchemaResolver.resolveResourceSchema(pathItem, schema, schema.main, mutableListOf()) + val result = resolveForPatch(schema, "/docs/{id}") assertNull(result, "Should not use json-patch content type as resource schema") } @@ -255,8 +260,7 @@ class JsonPatchSchemaResolverTest { } """.trimIndent()) - val pathItem = schema.main.schemaParsed.paths["/cats/{id}"]!! - val result = JsonPatchSchemaResolver.resolveResourceSchema(pathItem, schema, schema.main, mutableListOf()) + val result = resolveForPatch(schema, "/cats/{id}") assertNotNull(result) val props = result!!.properties