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/7] 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/7] 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/7] 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/7] 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/7] 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 From 51ece9d9a244211f973ad1e17fa523fe2bf81962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ROMINA=20JULIETA=20SU=C3=81REZ?= Date: Sun, 7 Jun 2026 23:16:45 -0300 Subject: [PATCH 6/7] Fixes for pr review 1 --- .../v3/jsonpatch/JsonPatchApplication.kt | 167 ------------------ .../v3/jsonpatch/JsonPatchController.kt | 5 - .../openapi/v3/jsonpatch/JsonPatchTest.kt | 59 ------- .../evomaster/core/output/dto/DtoWriter.kt | 4 + .../rest/builder/RestActionBuilderV3.kt | 9 + .../core/output/dto/DtoWriterTest.kt | 20 --- 6 files changed, 13 insertions(+), 251 deletions(-) delete mode 100644 core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchApplication.kt delete mode 100644 core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchController.kt delete mode 100644 core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/jsonpatch/JsonPatchTest.kt 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 deleted file mode 100644 index 0527407848..0000000000 --- a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchApplication.kt +++ /dev/null @@ -1,167 +0,0 @@ -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 -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("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.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.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 = 0) \ No newline at end of file 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 deleted file mode 100644 index 8155e9f338..0000000000 --- a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/jsonpatch/JsonPatchController.kt +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index c8346c61cc..0000000000 --- a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/jsonpatch/JsonPatchTest.kt +++ /dev/null @@ -1,59 +0,0 @@ -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", - 2000, - true, - { 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) - }, - 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 590ca5ccdd..7a0351a2eb 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 @@ -119,6 +119,10 @@ 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 isPrimitiveGene(gene) -> return else -> { 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 98bd2bf145..6f9fd44dbc 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 @@ -753,6 +753,15 @@ object RestActionBuilderV3 { val name: String var gene: Gene if (isJsonPatch) { + /* + The body is a JSON Patch document (RFC 6902), not a regular object, so it is not built + from the media type schema. resolveResourceSchema returns the OpenAPI Schema of the resource + being patched, found by inspecting sibling operations on the same path (GET 2xx response, + else PUT/POST requestBody). We turn that schema into a gene via getGene so the patch + operations reference real fields/paths of the resource, and use it to seed the + JsonPatchDocumentGene. If no resource schema is found, the gene is still built with + resourceGene == null and emits generic, structurally valid operations. + */ name = "body" val patchResourceSchema = JsonPatchSchemaResolver.resolveResourceSchema( operation, 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 ecf446db49..f722178a4c 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,13 +9,10 @@ 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` @@ -98,23 +95,6 @@ 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 257300be7bbe720bc241249ecab5fef7aa84a728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ROMINA=20JULIETA=20SU=C3=81REZ?= Date: Sat, 13 Jun 2026 20:06:56 -0300 Subject: [PATCH 7/7] Fix for andrea's comments --- .../bb/jsonpatch/BBJsonPatchApplication.kt | 38 ---- .../output/service/HttpWsTestCaseWriter.kt | 4 + .../builder/JsonPatchSchemaResolverTest.kt | 203 ++---------------- .../jsonpatch/json-patch-schema-resolver.json | 151 +++++++++++++ 4 files changed, 169 insertions(+), 227 deletions(-) create mode 100644 core/src/test/resources/swagger/artificial/jsonpatch/json-patch-schema-resolver.json 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 a859ce58e8..99ab42e66a 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 @@ -2,8 +2,6 @@ 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 @@ -30,20 +28,12 @@ open class BBJsonPatchApplication { 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.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 (parsePatchDocument(body) == null) @@ -53,58 +43,30 @@ open class BBJsonPatchApplication { 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)) 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 56221bd711..44e268d9b4 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 @@ -29,6 +29,7 @@ import org.evomaster.core.search.gene.Gene import org.evomaster.core.search.gene.ObjectGene import org.evomaster.core.search.gene.collection.ArrayGene import org.evomaster.core.search.gene.collection.FixedMapGene +import org.evomaster.core.search.gene.jsonpatch.JsonPatchDocumentGene import org.evomaster.core.search.gene.utils.GeneUtils import org.evomaster.core.search.gene.wrapper.ChoiceGene import org.slf4j.LoggerFactory @@ -127,6 +128,9 @@ 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 choiceGene = primaryGene.getWrappedGene(ChoiceGene::class.java) val actionName = call.getName() if (choiceGene != null) { 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 def0cc541b..d2c6b2d9ff 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 @@ -8,19 +8,16 @@ 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 } + companion object { + private val schema: RestSchema by lazy { + val json = JsonPatchSchemaResolverTest::class.java + .getResourceAsStream("/swagger/artificial/jsonpatch/json-patch-schema-resolver.json")!! + .bufferedReader().readText() + RestSchema(OpenApiAccess.parseOpenApi(json, SchemaLocation.MEMORY)) } - """.trimIndent() + } private fun resolveForPatch( - schema: RestSchema, path: String, messages: MutableList = mutableListOf() ) = JsonPatchSchemaResolver.resolveResourceSchema( @@ -32,41 +29,9 @@ class JsonPatchSchemaResolverTest { @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 messages = mutableListOf() - val result = resolveForPatch(schema, "/pets/{id}", messages) + val result = resolveForPatch("/pets/{id}", messages) assertNotNull(result) assertTrue(messages.isEmpty(), "Unexpected messages: $messages") @@ -78,34 +43,7 @@ class JsonPatchSchemaResolverTest { @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 result = resolveForPatch(schema, "/x/{id}") + val result = resolveForPatch("/x/{id}") assertNotNull(result) val props = result!!.properties @@ -115,26 +53,7 @@ class JsonPatchSchemaResolverTest { @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 result = resolveForPatch(schema, "/orders/{id}") + val result = resolveForPatch("/orders/{id}") assertNotNull(result) val props = result!!.properties @@ -144,24 +63,7 @@ class JsonPatchSchemaResolverTest { @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 result = resolveForPatch(schema, "/users") + val result = resolveForPatch("/users") assertNotNull(result) assertTrue(result!!.properties.containsKey("email")) @@ -169,98 +71,21 @@ class JsonPatchSchemaResolverTest { @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 result = resolveForPatch(schema, "/items/{id}") + val result = resolveForPatch("/items/{id}") 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 result = resolveForPatch(schema, "/docs/{id}") + val result = resolveForPatch("/docs/{id}") 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 result = resolveForPatch(schema, "/cats/{id}") + val result = resolveForPatch("/cats/{id}") assertNotNull(result) val props = result!!.properties diff --git a/core/src/test/resources/swagger/artificial/jsonpatch/json-patch-schema-resolver.json b/core/src/test/resources/swagger/artificial/jsonpatch/json-patch-schema-resolver.json new file mode 100644 index 0000000000..5d2cfa5c94 --- /dev/null +++ b/core/src/test/resources/swagger/artificial/jsonpatch/json-patch-schema-resolver.json @@ -0,0 +1,151 @@ +{ + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": { + "/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"}} + } + }, + "/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"}} + } + }, + "/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"}} + } + }, + "/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"}} + } + }, + "/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"}} + } + }, + "/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"}} + } + }, + "/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"} + } + } + } + } +}