diff --git a/core/src/main/kotlin/io/specmatic/conversions/ExampleFromFile.kt b/core/src/main/kotlin/io/specmatic/conversions/ExampleFromFile.kt index 22c964abf..2878de7fd 100644 --- a/core/src/main/kotlin/io/specmatic/conversions/ExampleFromFile.kt +++ b/core/src/main/kotlin/io/specmatic/conversions/ExampleFromFile.kt @@ -10,6 +10,7 @@ import io.specmatic.core.value.EmptyString import io.specmatic.core.value.JSONObjectValue import io.specmatic.core.value.Value import io.specmatic.mock.mockFromJSON +import io.specmatic.test.ExampleProcessor import java.io.File import java.net.URI @@ -17,30 +18,21 @@ class ExampleFromFile(val json: JSONObjectValue, val file: File) { fun toRow(specmaticConfig: SpecmaticConfig = SpecmaticConfig()): Row { logger.log("Loading test file ${this.expectationFilePath}") - val examples: Map = - headers - .plus(queryParams) - .plus(requestBody?.let { mapOf("(REQUEST-BODY)" to it.toStringLiteral()) } ?: emptyMap()) + val examples: Map = headers + .plus(queryParams) + .plus(requestBody?.let { mapOf("(REQUEST-BODY)" to it.toStringLiteral()) } ?: emptyMap()) - val ( - columnNames, - values - ) = examples.entries.let { entry -> + val (columnNames, values) = examples.entries.let { entry -> entry.map { it.key } to entry.map { it.value } } val responseExample: ResponseExample? = response.let { httpResponse -> when { - specmaticConfig.isResponseValueValidationEnabled() -> - ResponseValueExample(httpResponse) - - else -> - null + specmaticConfig.isResponseValueValidationEnabled() -> ResponseValueExample(httpResponse) + else -> null } - } - - val requestExample = mockFromJSON(json.jsonObject).getRequestWithAdditionalParamsIfAny(request, specmaticConfig.additionalExampleParamsFilePath) + val scenarioStub = mockFromJSON(json.jsonObject) return Row( columnNames, @@ -48,9 +40,10 @@ class ExampleFromFile(val json: JSONObjectValue, val file: File) { name = testName, fileSource = this.file.canonicalPath, responseExampleForValidation = responseExample, - requestExample = requestExample, - responseExample = response - ) + requestExample = scenarioStub.getRequestWithAdditionalParamsIfAny(specmaticConfig.additionalExampleParamsFilePath), + responseExample = response.takeUnless { this.isPartial() }, + isPartial = scenarioStub.partial != null + ).let { ExampleProcessor.resolveLookupIfPresent(it) } } constructor(file: File) : this( @@ -60,6 +53,15 @@ class ExampleFromFile(val json: JSONObjectValue, val file: File) { file = file ) + private fun JSONObjectValue.findByPath(path: String): Value? { + return findFirstChildByPath("partial.$path") ?: findFirstChildByPath(path) + } + + private fun isPartial(): Boolean { + // TODO: Review + return json.findByPath("partial") != null + } + val expectationFilePath: String = file.canonicalPath val response: HttpResponse @@ -88,12 +90,12 @@ class ExampleFromFile(val json: JSONObjectValue, val file: File) { ) } - val responseBody: Value? = attempt("Error reading response body in file ${file.parentFile.canonicalPath}") { - json.findFirstChildByPath("http-response.body") + val responseBody: Value? = attempt("Error reading response body in file ${file.canonicalPath}") { + json.findByPath("http-response.body") } - val responseHeaders: JSONObjectValue? = attempt("Error reading response headers in file ${file.parentFile.canonicalPath}") { - val headers = json.findFirstChildByPath("http-response.headers") ?: return@attempt null + val responseHeaders: JSONObjectValue? = attempt("Error reading response headers in file ${file.canonicalPath}") { + val headers = json.findByPath("http-response.headers") ?: return@attempt null if(headers !is JSONObjectValue) throw ContractException("http-response.headers should be a JSON object, but instead it was ${headers.toStringLiteral()}") @@ -101,18 +103,18 @@ class ExampleFromFile(val json: JSONObjectValue, val file: File) { headers } - val responseStatus: Int = attempt("Error reading status in file ${file.parentFile.canonicalPath}") { - json.findFirstChildByPath("http-response.status")?.toStringLiteral()?.toInt() + val responseStatus: Int = attempt("Error reading status in file ${file.canonicalPath}") { + json.findByPath("http-response.status")?.toStringLiteral()?.toInt() } ?: throw ContractException("Response status code was not found.") - val requestMethod: String = attempt("Error reading method in file ${file.parentFile.canonicalPath}") { - json.findFirstChildByPath("http-request.method")?.toStringLiteral() + val requestMethod: String = attempt("Error reading method in file ${file.canonicalPath}") { + json.findByPath("http-request.method")?.toStringLiteral() } ?: throw ContractException("Request method was not found.") private val rawPath: String? = - json.findFirstChildByPath("http-request.path")?.toStringLiteral() + json.findByPath("http-request.path")?.toStringLiteral() - val requestPath: String = attempt("Error reading path in file ${file.parentFile.canonicalPath}") { + val requestPath: String = attempt("Error reading path in file ${file.canonicalPath}") { rawPath?.let { pathOnly(it) } } ?: throw ContractException("Request path was not found.") @@ -120,21 +122,21 @@ class ExampleFromFile(val json: JSONObjectValue, val file: File) { return URI(requestPath).path ?: "" } - private val testName: String = attempt("Error reading expectation name in file ${file.parentFile.canonicalPath}") { - json.findFirstChildByPath("name")?.toStringLiteral() ?: file.nameWithoutExtension + private val testName: String = attempt("Error reading expectation name in file ${file.canonicalPath}") { + json.findByPath("name")?.toStringLiteral() ?: file.nameWithoutExtension } val queryParams: Map get() { - val path = attempt("Error reading path in file ${file.parentFile.canonicalPath}") { + val path = attempt("Error reading path in file ${file.canonicalPath}") { rawPath ?: throw ContractException("Request path was not found.") } val uri = URI.create(path) val queryParamsFromURL = parseQuery(uri.query) - val queryParamsFromJSONBlock = attempt("Error reading query params in file ${file.parentFile.canonicalPath}") { - (json.findFirstChildByPath("http-request.query") as JSONObjectValue?)?.jsonObject?.mapValues { (_, value) -> + val queryParamsFromJSONBlock = attempt("Error reading query params in file ${file.canonicalPath}") { + (json.findByPath("http-request.query") as JSONObjectValue?)?.jsonObject?.mapValues { (_, value) -> value.toStringLiteral() } ?: emptyMap() } @@ -142,13 +144,13 @@ class ExampleFromFile(val json: JSONObjectValue, val file: File) { return queryParamsFromURL + queryParamsFromJSONBlock } - val headers: Map = attempt("Error reading headers in file ${file.parentFile.canonicalPath}") { - (json.findFirstChildByPath("http-request.headers") as JSONObjectValue?)?.jsonObject?.mapValues { (_, value) -> + val headers: Map = attempt("Error reading headers in file ${file.canonicalPath}") { + (json.findByPath("http-request.headers") as JSONObjectValue?)?.jsonObject?.mapValues { (_, value) -> value.toStringLiteral() } ?: emptyMap() } - val requestBody: Value? = attempt("Error reading request body in file ${file.parentFile.canonicalPath}") { - json.findFirstChildByPath("http-request.body") + val requestBody: Value? = attempt("Error reading request body in file ${file.canonicalPath}") { + json.findByPath("http-request.body") } } diff --git a/core/src/main/kotlin/io/specmatic/core/Feature.kt b/core/src/main/kotlin/io/specmatic/core/Feature.kt index 25069e8de..fc230599d 100644 --- a/core/src/main/kotlin/io/specmatic/core/Feature.kt +++ b/core/src/main/kotlin/io/specmatic/core/Feature.kt @@ -542,6 +542,7 @@ data class Feature( concreteTestScenario.specification, concreteTestScenario.serviceType, comment, + validators = listOf(ExamplePostValidator), workflow = workflow, originalScenario = originalScenario ) diff --git a/core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt b/core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt index 1ab8d4ed4..812993631 100644 --- a/core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt @@ -12,6 +12,7 @@ import io.specmatic.core.discriminator.DiscriminatorBasedValueGenerator import io.specmatic.core.discriminator.DiscriminatorMetadata import io.specmatic.core.utilities.Flags import io.specmatic.core.utilities.Flags.Companion.EXTENSIBLE_QUERY_PARAMS +import io.specmatic.core.value.JSONArrayValue import io.specmatic.core.value.JSONObjectValue private const val MULTIPART_FORMDATA_BREADCRUMB = "MULTIPART-FORMDATA" @@ -201,11 +202,13 @@ data class HttpRequestPattern( val (httpRequest, resolver, failures) = parameters val result = try { + // TODO: Review Body String Logic val bodyValue = if (isPatternToken(httpRequest.bodyString)) StringValue(httpRequest.bodyString) - else - body.parse(httpRequest.bodyString, resolver) + else if (httpRequest.body is JSONObjectValue || httpRequest.body is JSONArrayValue) { + httpRequest.body + } else body.parse(httpRequest.bodyString, resolver) resolver.matchesPattern(null, body, bodyValue).breadCrumb("BODY") } catch (e: ContractException) { @@ -612,7 +615,7 @@ data class HttpRequestPattern( val value = it.parse(example, resolver) val requestBodyAsIs = if (!isInvalidRequestResponse(status)) { - val result = body.matches(value, resolver) + val result = resolver.matchesPattern(null, body, value) if (result is Failure) throw ContractException(result.toFailureReport()) diff --git a/core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt b/core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt index 610fac4da..862916a1d 100644 --- a/core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt @@ -76,7 +76,7 @@ data class HttpResponsePattern( fun withResponseExampleValue(row: Row, resolver: Resolver): HttpResponsePattern = attempt(breadCrumb = "RESPONSE") { - val responseExample: ResponseExample = row.responseExampleForValidation ?: return@attempt this + val responseExample: ResponseExample = row.responseExampleForValidation.takeIf { !row.isPartial } ?: return@attempt this val responseExampleMatchResult = matches(responseExample.responseExample, resolver) @@ -126,7 +126,7 @@ data class HttpResponsePattern( else -> response.body } - val result = body.matches(parsedValue, resolver) + val result = resolver.matchesPattern(null, body, parsedValue) if(result is Result.Failure) return MatchSuccess(Triple(response, resolver, failures.plus(result.breadCrumb("BODY")))) diff --git a/core/src/main/kotlin/io/specmatic/core/Scenario.kt b/core/src/main/kotlin/io/specmatic/core/Scenario.kt index 29ce73463..e1a54edf8 100644 --- a/core/src/main/kotlin/io/specmatic/core/Scenario.kt +++ b/core/src/main/kotlin/io/specmatic/core/Scenario.kt @@ -81,7 +81,8 @@ data class Scenario( val disambiguate: () -> String = { "" }, val descriptionFromPlugin: String? = null, val dictionary: Map = emptyMap(), - val attributeSelectionPattern: AttributeSelectionPattern = AttributeSelectionPattern() + val attributeSelectionPattern: AttributeSelectionPattern = AttributeSelectionPattern(), + val exampleRow: Row? = null ): ScenarioDetailsForResult { constructor(scenarioInfo: ScenarioInfo) : this( scenarioInfo.scenarioName, @@ -423,6 +424,7 @@ data class Scenario( expectedFacts = newExpectedServerState, ignoreFailure = ignoreFailure, exampleName = row.name, + exampleRow = row, generativePrefix = generativePrefix, ) } @@ -444,9 +446,7 @@ data class Scenario( } } - fun validExamplesOrException( - flagsBased: FlagsBased, - ) { + fun validExamplesOrException(flagsBased: FlagsBased) { val rowsToValidate = examples.flatMap { it.rows } val updatedResolver = flagsBased.update(resolver) @@ -709,6 +709,9 @@ data class Scenario( } fun isA2xxScenario(): Boolean = this.httpResponsePattern.status in 200..299 + + fun isA4xxScenario(): Boolean = this.httpResponsePattern.status in 400..499 + fun negativeBasedOn(badRequestOrDefault: BadRequestOrDefault?): Scenario { return this.copy( isNegative = true, diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/Discriminator.kt b/core/src/main/kotlin/io/specmatic/core/pattern/Discriminator.kt index 34d5ed2e3..a320383b6 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/Discriminator.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/Discriminator.kt @@ -5,6 +5,7 @@ import io.specmatic.core.Resolver import io.specmatic.core.Result import io.specmatic.core.value.JSONObjectValue import io.specmatic.core.value.Value +import io.specmatic.test.ExampleProcessor class Discriminator( val property: String, @@ -77,6 +78,8 @@ class Discriminator( discriminatorCsvClause ) + if (isPatternToken(actualDiscriminatorValue) || ExampleProcessor.isSubstitutionToken(actualDiscriminatorValue)) return Result.Success() + if (actualDiscriminatorValue.toStringLiteral() !in values) { val message = "Expected the value of discriminator property to be $discriminatorCsvClause but it was ${actualDiscriminatorValue.toStringLiteral()}" diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/ExactValuePattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/ExactValuePattern.kt index 1aa3187ef..189846c78 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/ExactValuePattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/ExactValuePattern.kt @@ -9,6 +9,8 @@ import io.specmatic.core.value.Value data class ExactValuePattern(override val pattern: Value, override val typeAlias: String? = null, val discriminator: Boolean = false) : Pattern { override fun matches(sampleData: Value?, resolver: Resolver): Result { + if (isPatternToken(sampleData)) return Result.Success() + return when (pattern == sampleData) { true -> Result.Success() else -> { diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/Row.kt b/core/src/main/kotlin/io/specmatic/core/pattern/Row.kt index f0b409d13..44f661396 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/Row.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/Row.kt @@ -18,7 +18,8 @@ data class Row( val requestBodyJSONExample: JSONExample? = null, val responseExampleForValidation: ResponseExample? = null, val requestExample: HttpRequest? = null, - val responseExample: HttpResponse? = null + val responseExample: HttpResponse? = null, + val isPartial: Boolean = false ) { constructor(examples: Map) :this(examples.keys.toList(), examples.values.toList()) diff --git a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt index d7f2ebbf4..069868192 100644 --- a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt +++ b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt @@ -43,14 +43,20 @@ data class ScenarioStub( return JSONObjectValue(mockInteraction) } - fun getRequestWithAdditionalParamsIfAny(request: HttpRequest, additionalExampleParamsFilePath: String?): HttpRequest { + private fun getHttpRequest(): HttpRequest { + return this.partial?.request ?: this.request + } + + fun getRequestWithAdditionalParamsIfAny(additionalExampleParamsFilePath: String?): HttpRequest { + val request = getHttpRequest() + if(additionalExampleParamsFilePath == null) - return this.request + return request val additionalExampleParamsFile = File(additionalExampleParamsFilePath) if (!additionalExampleParamsFile.exists() || !additionalExampleParamsFile.isFile) { - return this.request + return request } try { @@ -61,22 +67,22 @@ data class ScenarioStub( if(additionalExampleParams == null) { logger.log("WARNING: The content of $additionalExampleParamsFilePath is not a valid JSON object") - return this.request + return request } val additionalHeaders = (additionalExampleParams["headers"] ?: emptyMap()) as? Map if(additionalHeaders == null) { logger.log("WARNING: The content of \"headers\" in $additionalExampleParamsFilePath is not a valid JSON object") - return this.request + return request } - val updatedHeaders = this.request.headers.plus(additionalHeaders) + val updatedHeaders = request.headers.plus(additionalHeaders) - return this.request.copy(headers = updatedHeaders) + return request.copy(headers = updatedHeaders) } catch (e: Exception) { logger.log(e, "WARNING: Could not read additional example params file $additionalExampleParamsFilePath") - return this.request + return request } } diff --git a/core/src/main/kotlin/io/specmatic/test/ContractTest.kt b/core/src/main/kotlin/io/specmatic/test/ContractTest.kt index cc2e95804..13f602f0d 100644 --- a/core/src/main/kotlin/io/specmatic/test/ContractTest.kt +++ b/core/src/main/kotlin/io/specmatic/test/ContractTest.kt @@ -1,12 +1,19 @@ package io.specmatic.test +import io.specmatic.core.HttpRequest import io.specmatic.core.HttpResponse import io.specmatic.core.Result import io.specmatic.core.Scenario import io.specmatic.core.filters.ScenarioMetadata interface ResponseValidator { - fun validate(scenario: Scenario, httpResponse: HttpResponse): Result? + fun validate(scenario: Scenario, httpResponse: HttpResponse): Result? { + return null + } + + fun postValidate(scenario: Scenario, httpRequest: HttpRequest, httpResponse: HttpResponse): Result? { + return null + } } interface ContractTest { diff --git a/core/src/main/kotlin/io/specmatic/test/ExamplePostValidator.kt b/core/src/main/kotlin/io/specmatic/test/ExamplePostValidator.kt new file mode 100644 index 000000000..d9cc2db58 --- /dev/null +++ b/core/src/main/kotlin/io/specmatic/test/ExamplePostValidator.kt @@ -0,0 +1,63 @@ +package io.specmatic.test + +import io.specmatic.core.* +import io.specmatic.core.pattern.Row +import io.specmatic.core.value.* +import io.specmatic.test.asserts.Assert +import io.specmatic.test.asserts.parsedAssert + +object ExamplePostValidator: ResponseValidator { + + override fun postValidate(scenario: Scenario, httpRequest: HttpRequest, httpResponse: HttpResponse): Result? { + val asserts = scenario.exampleRow?.toAsserts()?.takeIf { it.isNotEmpty() } ?: return null + + val actualFactStore = httpRequest.toFactStore() + ExampleProcessor.getFactStore() + val currentFactStore = httpResponse.toFactStore() + + val results = asserts.map { it.assert(currentFactStore, actualFactStore) } + + val finalResults = results.filterIsInstance().ifEmpty { return Result.Success() } + return Result.fromFailures(finalResults) + } + + private fun Row.toAsserts(): List { + val responseExampleBody = this.responseExampleForValidation?.responseExample ?: return emptyList() + + val headerAsserts = responseExampleBody.headers.map { + parsedAssert("RESPONSE.HEADERS", it.key, StringValue(it.value)) + }.filterNotNull() + + return responseExampleBody.body.traverse( + onScalar = { value, key -> mapOf(key to parsedAssert("RESPONSE.BODY", key, value)) }, + onAssert = { value, key -> mapOf(key to parsedAssert("RESPONSE.BODY", key, value)) } + ).values.filterNotNull() + headerAsserts + } + + private fun HttpRequest.toFactStore(): Map { + val queryParams = this.queryParams.asMap().map { + "REQUEST.QUERY-PARAMS.${it.key}" to StringValue(it.value) + }.toMap() + + val headers = this.headers.map { + "REQUEST.HEADERS.${it.key}" to StringValue(it.value) + }.toMap() + + return this.body.traverse( + prefix = "REQUEST.BODY", + onScalar = { value, key -> mapOf(key to value) }, + onComposite = { value, key -> mapOf(key to value) } + ) + queryParams + headers + } + + private fun HttpResponse.toFactStore(): Map { + val headers = this.headers.map { + "RESPONSE.HEADERS.${it.key}" to StringValue(it.value) + }.toMap() + + return this.body.traverse( + prefix = "RESPONSE.BODY", + onScalar = { value, key -> mapOf(key to value) }, + onComposite = { value, key -> mapOf(key to value) } + ) + headers + } +} diff --git a/core/src/main/kotlin/io/specmatic/test/ExamplePreProcessor.kt b/core/src/main/kotlin/io/specmatic/test/ExamplePreProcessor.kt new file mode 100644 index 000000000..89ceeeb2d --- /dev/null +++ b/core/src/main/kotlin/io/specmatic/test/ExamplePreProcessor.kt @@ -0,0 +1,250 @@ +package io.specmatic.test + +import io.ktor.util.* +import io.specmatic.core.* +import io.specmatic.core.log.consoleLog +import io.specmatic.core.pattern.* +import io.specmatic.core.utilities.exceptionCauseMessage +import io.specmatic.core.value.* +import io.specmatic.test.asserts.isKeyAssert +import java.io.File + +const val delayedRandomSubstitutionKey = "\$rand" +val SUBSTITUTE_PATTERN = Regex("^\\$(\\w+)?\\((.*)\\)$") + +enum class SubstitutionType { SIMPLE, DELAYED_RANDOM } + +enum class StoreType { REPLACE, MERGE } + +object ExampleProcessor { + private var runningEntity: Map = mapOf() + private val factStore: Map = loadConfig().toFactStore("CONFIG") + + private fun loadConfig(): JSONObjectValue { + val configFilePath = runCatching { + loadSpecmaticConfig().additionalExampleParamsFilePath + }.getOrNull() ?: return JSONObjectValue(emptyMap()) + + val configFile = File(configFilePath) + if (!configFile.exists()) { + consoleLog("Could not find the CONFIG at path ${configFile.canonicalPath}") + return JSONObjectValue(emptyMap()) + } + + return runCatching { parsedJSONObject(configFile.readText()) }.getOrElse { e -> + consoleLog("Error loading CONFIG $configFilePath: ${exceptionCauseMessage(e)}") + JSONObjectValue(emptyMap()) + }.also { + it.findFirstChildByPath("url")?.let { + url -> System.setProperty("testBaseURL", url.toStringLiteral()) + } + } + } + + private fun defaultIfNotExits(lookupKey: String, type: SubstitutionType = SubstitutionType.SIMPLE): Value { + throw ContractException("Could not resolve $lookupKey, key does not exist in fact store") + } + + private fun ifNotExitsToLookupPattern(lookupKey: String, type: SubstitutionType = SubstitutionType.SIMPLE): Value { + return when (type) { + SubstitutionType.SIMPLE -> StringValue("$($lookupKey)") + SubstitutionType.DELAYED_RANDOM -> StringValue("$delayedRandomSubstitutionKey($lookupKey)") + } + } + + fun getFactStore(): Map { + return factStore + runningEntity + } + + private fun getValue(key: String, type: SubstitutionType): Value? { + val returnValue = factStore[key] ?: runningEntity[key] + if (type != SubstitutionType.DELAYED_RANDOM) return returnValue + + val arrayValue = returnValue as? JSONArrayValue + ?: throw ContractException("$key is not an array in fact store") + + val entityKey = "ENTITY.${key.substringAfterLast('.')}" + val entityValue = factStore[entityKey] ?: runningEntity[entityKey] + ?: throw ContractException("Could not resolve $entityKey in fact store") + + val filteredList = arrayValue.list.filterNot { it.toStringLiteral() == entityValue.toStringLiteral() }.ifEmpty { + throw ContractException("Couldn't pick a random value from $key that was not equal to $entityValue") + } + + return filteredList.random() + } + + /* RESOLVER HELPERS */ + fun resolveLookupIfPresent(row: Row): Row { + return row.copy( + requestExample = row.requestExample?.let { resolve(it, ::ifNotExitsToLookupPattern) }, + responseExample = row.responseExample?.let { resolve(it, ::ifNotExitsToLookupPattern) }, + values = row.values.map { resolve(parsedValue(it), ::ifNotExitsToLookupPattern).toStringLiteral() } + ) + } + + fun resolve(httpRequest: HttpRequest, ifNotExists: (lookupKey: String, type: SubstitutionType) -> Value = ::defaultIfNotExits): HttpRequest { + return httpRequest.copy( + method = httpRequest.method, + path = httpRequest.parsePath().joinToString("/", prefix = "/", postfix = "/".takeIf { httpRequest.path?.endsWith('/') == true }.orEmpty()) { + resolve(it, ifNotExists) + }, + headers = resolve(httpRequest.headers, ifNotExists), + body = resolve(httpRequest.body, ifNotExists), + queryParams = QueryParameters(resolve(httpRequest.queryParams.paramPairs, ifNotExists)) + ) + } + + fun resolve(httpResponse: HttpResponse, ifNotExists: (lookupKey: String, type: SubstitutionType) -> Value = ::defaultIfNotExits): HttpResponse { + return httpResponse.copy( + status = httpResponse.status, + headers = resolve(httpResponse.headers, ifNotExists), + body = resolve(httpResponse.body, ifNotExists) + ) + } + + fun resolve(entries: List>, ifNotExists: (lookupKey: String, type: SubstitutionType) -> Value): List> { + val keysToAvoid = entries.groupBy { it.first }.filter { it.value.size > 1 }.keys + return entries.map { (key, value) -> + val updatedValue = if (key in keysToAvoid) value else resolve(value, ifNotExists) + key to updatedValue + } + } + + fun resolve(value: Map, ifNotExists: (lookupKey: String, type: SubstitutionType) -> Value): Map { + return value.mapValues { (_, value) -> resolve(value, ifNotExists) } + } + + private fun resolve(value: String, ifNotExists: (lookupKey: String, type: SubstitutionType) -> Value): String { + return value.ifSubstitutionToken { token, type -> + if (type == SubstitutionType.DELAYED_RANDOM && ifNotExists == ::ifNotExitsToLookupPattern ) { + return@ifSubstitutionToken ifNotExitsToLookupPattern(token, type).toStringLiteral() + } else getValue(token, type)?.toStringLiteral() ?: ifNotExists(token, type).toStringLiteral() + } ?: value + } + + private fun resolve(value: Value, ifNotExists: (lookupKey: String, type: SubstitutionType) -> Value): Value { + return when (value) { + is StringValue -> resolve(value, ifNotExists) + is JSONObjectValue -> resolve(value, ifNotExists) + is JSONArrayValue -> resolve(value, ifNotExists) + else -> value + } + } + + private fun resolve(value: StringValue, ifNotExists: (lookupKey: String, type: SubstitutionType) -> Value): Value { + return value.ifSubstitutionToken { token, type -> + if (type == SubstitutionType.DELAYED_RANDOM && ifNotExists == ::ifNotExitsToLookupPattern) { + return@ifSubstitutionToken ifNotExitsToLookupPattern(token, type) + } else getValue(token, type) ?: ifNotExists(token, type) + } ?: value + } + + private fun resolve(value: JSONObjectValue, ifNotExists: (lookupKey: String, type: SubstitutionType) -> Value): JSONObjectValue { + return JSONObjectValue(value.jsonObject.mapValues { (_, value) -> resolve(value, ifNotExists) }) + } + + private fun resolve(value: JSONArrayValue, ifNotExists: (lookupKey: String, type: SubstitutionType) -> Value): JSONArrayValue { + return JSONArrayValue(value.list.map { resolve(it, ifNotExists) }) + } + + /* STORE HELPERS */ + fun store(exampleRow: Row, httpRequest: HttpRequest, httpResponse: HttpResponse) { + if (httpRequest.method == "POST") { + runningEntity = httpResponse.body.toFactStore(prefix = "ENTITY") + return + } + + val bodyToCheck = exampleRow.responseExample?.body ?: exampleRow.responseExampleForValidation?.responseExample?.body + bodyToCheck?.ifContainsStoreToken { type -> + runningEntity = when (type) { + StoreType.REPLACE -> httpResponse.body.toFactStore(prefix = "ENTITY") + StoreType.MERGE -> runningEntity.plus(httpResponse.body.toFactStore(prefix = "ENTITY")) + } + } + } + + private fun Value.ifContainsStoreToken(block: (storeType: StoreType) -> Unit) { + if (this !is JSONObjectValue) return + + this.findFirstChildByPath("\$store")?.let { + when (it.toStringLiteral().toLowerCasePreservingASCIIRules()){ + "merge" -> block(StoreType.MERGE) + else -> block(StoreType.REPLACE) + } + } + } + + /* PARSER HELPERS */ + private fun Value.toFactStore(prefix: String = ""): Map { + return this.traverse( + prefix = prefix, + onScalar = { scalar, key -> mapOf(key to scalar) }, + onComposite = { composite, key -> mapOf(key to composite) } + ) + } + + private fun HttpRequest.parsePath(): List { + return path?.trim('/')?.split("/") ?: emptyList() + } + + private fun T.ifSubstitutionToken(block: (lookupKey: String, type: SubstitutionType) -> T): T? { + return if (isSubstitutionToken(this)) { + val type = if (this.toString().contains(delayedRandomSubstitutionKey)) { + SubstitutionType.DELAYED_RANDOM + } else SubstitutionType.SIMPLE + + block(withoutSubstituteDelimiters(this), type) + } else null + } + + fun isSubstitutionToken(token: T): Boolean { + return when (token) { + is String -> SUBSTITUTE_PATTERN.matchEntire(token) != null + is StringValue -> SUBSTITUTE_PATTERN.matchEntire(token.string) != null + else -> false + } + } + + private fun withoutSubstituteDelimiters(token: T): String { + return when (token) { + is String -> SUBSTITUTE_PATTERN.find(token)?.groups?.get(2)?.value ?: "" + is StringValue -> SUBSTITUTE_PATTERN.find(token.string)?.groups?.get(2)?.value ?: "" + else -> "" + } + } +} + +internal fun Value.traverse( + prefix: String = "", onScalar: (Value, String) -> Map, + onComposite: ((Value, String) -> Map)? = null, onAssert: ((Value, String) -> Map)? = null +): Map { + return when (this) { + is JSONObjectValue -> this.traverse(prefix, onScalar, onComposite, onAssert) + is JSONArrayValue -> this.traverse(prefix, onScalar, onComposite, onAssert) + is ScalarValue -> onScalar(this, prefix) + else -> emptyMap() + }.filterValues { it != null } +} + +private fun JSONObjectValue.traverse( + prefix: String = "", onScalar: (Value, String) -> Map, + onComposite: ((Value, String) -> Map)? = null, onAssert: ((Value, String) -> Map)? = null +): Map { + return this.jsonObject.entries.flatMap { (key, value) -> + val fullKey = if (prefix.isNotEmpty()) "$prefix.$key" else key + key.isKeyAssert { + onAssert?.invoke(value, fullKey)?.entries.orEmpty() + } ?: value.traverse(fullKey, onScalar, onComposite, onAssert).entries + }.associate { it.toPair() } + onComposite?.invoke(this, prefix).orEmpty() +} + +private fun JSONArrayValue.traverse( + prefix: String = "", onScalar: (Value, String) -> Map, + onComposite: ((Value, String) -> Map)? = null, onAssert: ((Value, String) -> Map)? = null +): Map { + return this.list.mapIndexed { index, value -> + val fullKey = if (onAssert != null) { prefix } else "$prefix[$index]" + value.traverse(fullKey, onScalar, onComposite, onAssert) + }.flatMap { it.entries }.associate { it.toPair() } + onComposite?.invoke(this, prefix).orEmpty() +} diff --git a/core/src/main/kotlin/io/specmatic/test/ScenarioAsTest.kt b/core/src/main/kotlin/io/specmatic/test/ScenarioAsTest.kt index 63834b863..149f1b5da 100644 --- a/core/src/main/kotlin/io/specmatic/test/ScenarioAsTest.kt +++ b/core/src/main/kotlin/io/specmatic/test/ScenarioAsTest.kt @@ -2,7 +2,6 @@ package io.specmatic.test import io.specmatic.conversions.convertPathParameterStyle import io.specmatic.core.* -import io.specmatic.core.filters.ScenarioMetadata import io.specmatic.core.log.HttpLogMessage import io.specmatic.core.log.LogMessage import io.specmatic.core.log.logger @@ -88,21 +87,38 @@ data class ScenarioAsTest( workflow.updateRequest(it, originalScenario) } - return try { - testExecutor.setServerState(testScenario.serverState) + try { + val updatedRequest = ExampleProcessor.resolve(request) - testExecutor.preExecuteScenario(testScenario, request) + val substitutionResult = originalScenario.httpRequestPattern.matches(updatedRequest, flagsBased.update(originalScenario.resolver)) + if (substitutionResult is Result.Failure && !testScenario.isA4xxScenario() && !testScenario.isNegative) { + return Pair(substitutionResult.updateScenario(testScenario), null) + } - val response = testExecutor.execute(request) + testExecutor.setServerState(testScenario.serverState) + testExecutor.preExecuteScenario(testScenario, updatedRequest) + val response = testExecutor.execute(updatedRequest) + //TODO: Review - Do we need workflow anymore workflow.extractDataFrom(response, originalScenario) val validatorResult = validators.asSequence().map { it.validate(scenario, response) }.filterNotNull().firstOrNull() - val result = validatorResult ?: testResult(request, response, testScenario, flagsBased) + if (validatorResult is Result.Failure) { + return Pair(validatorResult.withBindings(testScenario.bindings, response), response) + } + + val testResult = testResult(updatedRequest, response, testScenario, flagsBased) + if (testResult is Result.Failure) { + return Pair(testResult.withBindings(testScenario.bindings, response), response) + } + + val postValidateResult = validators.asSequence().map { it.postValidate(testScenario, updatedRequest, response) }.filterNotNull().firstOrNull() + val result = postValidateResult ?: testResult - Pair(result.withBindings(testScenario.bindings, response), response) + testScenario.exampleRow?.let { ExampleProcessor.store(it, updatedRequest, response) } + return Pair(result.withBindings(testScenario.bindings, response), response) } catch (exception: Throwable) { - Pair( + return Pair( Result.Failure(exceptionCauseMessage(exception)) .also { failure -> failure.updateScenario(testScenario) }, null) } @@ -116,8 +132,7 @@ data class ScenarioAsTest( ): Result { val result = when { - response.specmaticResultHeaderValue() == "failure" -> Result.Failure(response.body.toStringLiteral()) - .updateScenario(testScenario) + response.specmaticResultHeaderValue() == "failure" -> Result.Failure(response.body.toStringLiteral()).updateScenario(testScenario) else -> testScenario.matches(request, response, ContractAndResponseMismatch, flagsBased?.unexpectedKeyCheck ?: ValidateUnexpectedKeys) } diff --git a/core/src/main/kotlin/io/specmatic/test/asserts/Assert.kt b/core/src/main/kotlin/io/specmatic/test/asserts/Assert.kt new file mode 100644 index 000000000..ee2e6a9ce --- /dev/null +++ b/core/src/main/kotlin/io/specmatic/test/asserts/Assert.kt @@ -0,0 +1,47 @@ +package io.specmatic.test.asserts + +import io.specmatic.core.Result +import io.specmatic.core.value.JSONArrayValue +import io.specmatic.core.value.Value + +interface Assert { + fun assert(currentFactStore: Map, actualFactStore: Map): Result + + fun dynamicAsserts(prefixValue: Value): List + + fun List.toResult(): Result { + val failures = filterIsInstance() + return if (failures.isNotEmpty()) { + Result.fromFailures(failures) + } else Result.Success() + } + + fun List.toResultIfAny(): Result { + return this.firstOrNull { it is Result.Success } ?: this.toResult() + } + + val prefix: String + val key: String +} + +fun parsedAssert(prefix: String, key: String, value: Value): Assert? { + return when (key) { + "\$if" -> AssertConditional.parse(prefix, key, value) + else -> { + return AssertComparison.parse(prefix, key, value) ?: AssertArray.parse(prefix, key, value) + } + } +} + +fun Value.suffixIfMoreThanOne(block: (suffix: String, suffixValue: Value) -> T): List { + return when (this) { + is JSONArrayValue -> this.list.mapIndexed { index, value -> block("[$index]", value) } + else -> listOfNotNull(block("", this)) + } +} + +fun String.isKeyAssert(block: (String) -> T): T? { + return if (this.startsWith("\$if")) { + block(this) + } else null +} diff --git a/core/src/main/kotlin/io/specmatic/test/asserts/AssertArray.kt b/core/src/main/kotlin/io/specmatic/test/asserts/AssertArray.kt new file mode 100644 index 000000000..d48f5b18d --- /dev/null +++ b/core/src/main/kotlin/io/specmatic/test/asserts/AssertArray.kt @@ -0,0 +1,45 @@ +package io.specmatic.test.asserts + +import io.specmatic.core.Result +import io.specmatic.core.value.JSONArrayValue +import io.specmatic.core.value.Value + +enum class ArrayAssertType { ARRAY_HAS } + +class AssertArray(override val prefix: String, override val key: String, private val lookupKey: String, private val arrayAssertType: ArrayAssertType): Assert { + override fun assert(currentFactStore: Map, actualFactStore: Map): Result { + val prefixValue = currentFactStore[prefix] ?: return Result.Failure(breadCrumb = prefix, message = "Could not resolve $prefix in current fact store") + if (prefixValue !is JSONArrayValue) { + return Result.Failure(breadCrumb = prefix, message = "Expected $prefix to be an array") + } + + return when (arrayAssertType) { + ArrayAssertType.ARRAY_HAS -> assertArrayHas(prefixValue, currentFactStore, actualFactStore) + } + } + + private fun assertArrayHas(prefixValue: JSONArrayValue, currentFactStore: Map, actualFactStore: Map): Result { + val expectedValue = actualFactStore[lookupKey] ?: return Result.Failure(breadCrumb = lookupKey, message = "Could not resolve $lookupKey in actual fact store") + val asserts = AssertComparison(prefix = prefix, key = key, lookupKey = lookupKey, isEqualityCheck = true).dynamicAsserts(prefixValue) + val result = asserts.map { it.assert(currentFactStore, actualFactStore) }.toResultIfAny() + + return when (result) { + is Result.Success -> Result.Success() + is Result.Failure -> Result.Failure("None of the values in $prefix[*].$key matched $lookupKey of value ${expectedValue.displayableValue()}", breadCrumb = prefix) + } + } + + override fun dynamicAsserts(prefixValue: Value): List { return listOf(this) } + + companion object { + fun parse(prefix: String, key: String, value: Value): AssertArray? { + val match = ASSERT_PATTERN.find(value.toStringLiteral()) ?: return null + val keyPrefix = prefix.removeSuffix(".${key}") + + return when (match.groupValues[1]) { + "array_has" -> AssertArray(keyPrefix, key, match.groupValues[2], ArrayAssertType.ARRAY_HAS) + else -> null + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/specmatic/test/asserts/AssertComparison.kt b/core/src/main/kotlin/io/specmatic/test/asserts/AssertComparison.kt new file mode 100644 index 000000000..07e2863de --- /dev/null +++ b/core/src/main/kotlin/io/specmatic/test/asserts/AssertComparison.kt @@ -0,0 +1,54 @@ +package io.specmatic.test.asserts + +import io.specmatic.core.Result +import io.specmatic.core.value.Value + +val ASSERT_PATTERN = Regex("^\\$(\\w+)\\((.*)\\)$") + +class AssertComparison(override val prefix: String, override val key: String, val lookupKey: String, private val isEqualityCheck: Boolean): Assert { + + override fun assert(currentFactStore: Map, actualFactStore: Map): Result { + val prefixValue = currentFactStore[prefix] ?: return Result.Failure(breadCrumb = prefix, message = "Could not resolve $prefix in current fact store") + val expectedValue = actualFactStore[lookupKey] ?: return Result.Failure(breadCrumb = lookupKey, message = "Could not resolve $lookupKey in actual fact store") + + val dynamicList = dynamicAsserts(prefixValue) + val results = dynamicList.map { newAssert -> + val finalKey = "${newAssert.prefix}.${newAssert.key}" + val actualValue = currentFactStore[finalKey] ?: return@map Result.Failure(breadCrumb = finalKey, message = "Could not resolve $finalKey in current fact store") + assert(finalKey, actualValue, expectedValue) + } + + return results.toResult() + } + + override fun dynamicAsserts(prefixValue: Value): List { + return prefixValue.suffixIfMoreThanOne {suffix, _ -> + AssertComparison(prefix = "$prefix$suffix", key = key, lookupKey = lookupKey, isEqualityCheck = isEqualityCheck) + } + } + + private fun assert(finalKey: String, actualValue: Value, expectedValue: Value): Result { + val match = actualValue.toStringLiteral() == expectedValue.toStringLiteral() + val success = match == isEqualityCheck + return if (success) { Result.Success() } else { + val message = if (isEqualityCheck) "equal" else "not equal" + Result.Failure( + breadCrumb = finalKey, + message = "Expected ${actualValue.displayableValue()} to $message ${expectedValue.displayableValue()}" + ) + } + } + + companion object { + fun parse(prefix: String, key: String, value: Value): AssertComparison? { + val match = ASSERT_PATTERN.find(value.toStringLiteral()) ?: return null + val keyPrefix = prefix.removeSuffix(".${key}") + + return when (match.groupValues[1]) { + "eq" -> AssertComparison(keyPrefix, key, match.groupValues[2], isEqualityCheck = true) + "neq" -> AssertComparison(keyPrefix, key, match.groupValues[2], isEqualityCheck = false) + else -> null + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/specmatic/test/asserts/AssertConditional.kt b/core/src/main/kotlin/io/specmatic/test/asserts/AssertConditional.kt new file mode 100644 index 000000000..5fda51fea --- /dev/null +++ b/core/src/main/kotlin/io/specmatic/test/asserts/AssertConditional.kt @@ -0,0 +1,66 @@ +package io.specmatic.test.asserts + +import io.specmatic.core.Result +import io.specmatic.core.value.JSONObjectValue +import io.specmatic.core.value.Value + +class AssertConditional(override val prefix: String, val conditionalAsserts: List, val thenAsserts: List, val elseAsserts: List): Assert { + + override fun assert(currentFactStore: Map, actualFactStore: Map): Result { + val prefixValue = currentFactStore[prefix] ?: return Result.Failure(breadCrumb = prefix, message = "Could not resolve $prefix in current fact store") + + val dynamicAsserts = this.dynamicAsserts(prefixValue) + val results = dynamicAsserts.map { + val mainResult = it.conditionalAsserts.map { assert -> assert.assert(currentFactStore, actualFactStore) }.toResult() + when (mainResult) { + is Result.Success -> it.thenAsserts.map { assert -> assert.assert(currentFactStore, actualFactStore) }.toResult() + else -> it.elseAsserts.map { assert -> assert.assert(currentFactStore, actualFactStore) }.toResult() + } + } + + return results.toResult() + } + + private fun collectDynamicAsserts(prefixValue: Value, asserts: List): Map> { + return asserts.flatMap { it.dynamicAsserts(prefixValue) }.groupBy { it.prefix } + } + + override fun dynamicAsserts(prefixValue: Value): List { + val newConditionalAsserts = collectDynamicAsserts(prefixValue, conditionalAsserts) + val newThenAsserts = collectDynamicAsserts(prefixValue, thenAsserts) + val newElseAsserts = collectDynamicAsserts(prefixValue, elseAsserts) + + return newConditionalAsserts.keys.map { prefix -> + AssertConditional( + prefix = prefix, + conditionalAsserts = newConditionalAsserts[prefix].orEmpty(), + thenAsserts = newThenAsserts[prefix].orEmpty(), + elseAsserts = newElseAsserts[prefix].orEmpty() + ) + } + } + + override val key: String = "" + + companion object { + private fun toAsserts(prefix: String, jsonObjectValue: JSONObjectValue?): List { + return jsonObjectValue?.jsonObject?.entries?.mapNotNull { (key, value) -> + parsedAssert(prefix, key, value) + }.orEmpty() + } + + fun parse(prefix: String, key: String, value: Value): AssertConditional? { + val conditions = (value as? JSONObjectValue)?.findFirstChildByPath("\$conditions") as? JSONObjectValue ?: return null + val thenConditions = (value as? JSONObjectValue)?.findFirstChildByPath("\$then") as? JSONObjectValue + val elseConditions = (value as? JSONObjectValue)?.findFirstChildByPath("\$else") as? JSONObjectValue + + if (thenConditions == null && elseConditions == null) return null + + return AssertConditional( + prefix = prefix, + conditionalAsserts = toAsserts(prefix, conditions), + thenAsserts = toAsserts(prefix, thenConditions), elseAsserts = toAsserts(prefix, elseConditions) + ) + } + } +} \ No newline at end of file diff --git a/junit5-support/src/main/kotlin/io/specmatic/test/reports/coverage/OpenApiCoverageReportInput.kt b/junit5-support/src/main/kotlin/io/specmatic/test/reports/coverage/OpenApiCoverageReportInput.kt index 3cec0e3b8..91a07a72b 100644 --- a/junit5-support/src/main/kotlin/io/specmatic/test/reports/coverage/OpenApiCoverageReportInput.kt +++ b/junit5-support/src/main/kotlin/io/specmatic/test/reports/coverage/OpenApiCoverageReportInput.kt @@ -49,7 +49,6 @@ class OpenApiCoverageReportInput( allTests = addTestResultsForTestsNotGeneratedBySpecmatic(allTests, allEndpoints) allTests = identifyWipTestsAndUpdateResult(allTests) allTests = checkForInvalidTestsAndUpdateResult(allTests) - allTests = sortByPathMethodResponseStatus(allTests) groupedTestResultRecords = groupTestsByPathMethodAndResponseStatus(allTests) groupedTestResultRecords.forEach { (route, methodMap) -> diff --git a/junit5-support/src/main/kotlin/io/specmatic/test/reports/renderers/CoverageReportHtmlRenderer.kt b/junit5-support/src/main/kotlin/io/specmatic/test/reports/renderers/CoverageReportHtmlRenderer.kt index 9bed02d36..ca7bccc81 100644 --- a/junit5-support/src/main/kotlin/io/specmatic/test/reports/renderers/CoverageReportHtmlRenderer.kt +++ b/junit5-support/src/main/kotlin/io/specmatic/test/reports/renderers/CoverageReportHtmlRenderer.kt @@ -5,7 +5,11 @@ import io.specmatic.core.ReportFormatterType import io.specmatic.core.SpecmaticConfig import io.specmatic.core.log.HttpLogMessage import io.specmatic.core.log.logger +import io.specmatic.core.utilities.Flags import io.specmatic.test.SpecmaticJUnitSupport +import io.specmatic.test.SpecmaticJUnitSupport.Companion.HOST +import io.specmatic.test.SpecmaticJUnitSupport.Companion.PORT +import io.specmatic.test.SpecmaticJUnitSupport.Companion.TEST_BASE_URL import io.specmatic.test.TestInteractionsLog.displayName import io.specmatic.test.TestInteractionsLog.duration import io.specmatic.test.TestResultRecord @@ -106,13 +110,13 @@ class CoverageReportHtmlRenderer : ReportRenderer it.scenario == test.scenarioResult?.scenario } val scenarioName = getTestName(test, matchingLogMessage) - val (requestString, requestTime) = getRequestString(matchingLogMessage) - val (responseString, responseTime) = getResponseString(matchingLogMessage) + val (requestString, requestTime) = getRequestString(test, matchingLogMessage) + val (responseString, responseTime) = getResponseString(test, matchingLogMessage) scenarioDataList.add( ScenarioData( name = scenarioName, - baseUrl = getBaseUrl(matchingLogMessage), + baseUrl = getBaseUrl(test, matchingLogMessage), duration = matchingLogMessage?.duration() ?: 0, testResult = test.result, valid = test.isValid, @@ -134,21 +138,24 @@ class CoverageReportHtmlRenderer : ReportRenderer } private fun getTestName(testResult: TestResultRecord, httpLogMessage: HttpLogMessage?): String { - return httpLogMessage?.displayName() ?: "Scenario: ${testResult.path} -> ${testResult.responseStatus}" + return httpLogMessage?.displayName() ?: testResult.scenarioResult?.scenario?.testDescription() ?: "Scenario: ${testResult.path} -> ${testResult.responseStatus}" } - private fun getBaseUrl(httpLogMessage: HttpLogMessage?): String { - return httpLogMessage?.targetServer ?: "Unknown baseURL" + private fun getBaseUrl(testResult: TestResultRecord, httpLogMessage: HttpLogMessage?): String { + val host = Flags.getStringValue(HOST).orEmpty() + val port = Flags.getStringValue(PORT).orEmpty() + val baseUrlFromFlags = Flags.getStringValue(TEST_BASE_URL) ?: if (host.isNotBlank() && port.isNotBlank()) "$host:$port" else null + return httpLogMessage?.targetServer ?: baseUrlFromFlags ?: "Unknown baseURL" } - private fun getRequestString(httpLogMessage: HttpLogMessage?): Pair { + private fun getRequestString(testResult: TestResultRecord, httpLogMessage: HttpLogMessage?): Pair { return Pair( httpLogMessage?.request?.toLogString() ?: "No Request", httpLogMessage?.requestTime?.toEpochMillis() ?: 0 ) } - private fun getResponseString(httpLogMessage: HttpLogMessage?): Pair { + private fun getResponseString(testResult: TestResultRecord, httpLogMessage: HttpLogMessage?): Pair { return Pair( httpLogMessage?.response?.toLogString() ?: "No Response", httpLogMessage?.responseTime?.toEpochMillis() ?: 0 diff --git a/junit5-support/src/main/resources/templates/assets/main.js b/junit5-support/src/main/resources/templates/assets/main.js index ac3e678e9..4607b3732 100644 --- a/junit5-support/src/main/resources/templates/assets/main.js +++ b/junit5-support/src/main/resources/templates/assets/main.js @@ -18,7 +18,8 @@ const mainElement = document.querySelector("main"); const responseSummary = document.querySelector("ul#response-summary"); const scenariosList = document.querySelector("ul#scenarios"); const reportTable = document.querySelector("table#reports"); -const [coverageTh, firstGroupTh, secondGroupTh, thirdGroupTh, ...rest] = Array.from(document.querySelector("table > thead > tr").children); +const columns = Array.from(document.querySelector("table > thead > tr").children); +const [coverageTh, firstGroupTh, secondGroupTh, thirdGroupTh, ...rest] = columns.length > 4 ? columns: [undefined, ...columns]; /* Functions */ function readJsonData() { diff --git a/junit5-support/src/test/kotlin/io/specmatic/test/ApiCoverageReportStatusTest.kt b/junit5-support/src/test/kotlin/io/specmatic/test/ApiCoverageReportStatusTest.kt index 6d18f7ae1..ff6819bdf 100644 --- a/junit5-support/src/test/kotlin/io/specmatic/test/ApiCoverageReportStatusTest.kt +++ b/junit5-support/src/test/kotlin/io/specmatic/test/ApiCoverageReportStatusTest.kt @@ -91,8 +91,8 @@ class ApiCoverageReportStatusTest { ).generate() assertThat(apiCoverageReport.coverageRows).isEqualTo( listOf( - OpenApiCoverageConsoleRow("GET", "/route1", 200, 1, 50, Remarks.Covered), - OpenApiCoverageConsoleRow("GET", "/route1", 400, 0, 50, Remarks.Missed, showPath = false, showMethod = false) + OpenApiCoverageConsoleRow("GET", "/route1", 400, 0, 50, Remarks.Missed), + OpenApiCoverageConsoleRow("GET", "/route1", 200, 1, 50, Remarks.Covered, showPath = false, showMethod = false) ) ) } @@ -118,8 +118,8 @@ class ApiCoverageReportStatusTest { ).generate() assertThat(apiCoverageReport.coverageRows).isEqualTo( listOf( - OpenApiCoverageConsoleRow("GET", "/route1", 200, 1, 50, Remarks.Covered), - OpenApiCoverageConsoleRow("GET", "/route1", 400, 0, 50, Remarks.Missed, showPath = false, showMethod = false) + OpenApiCoverageConsoleRow("GET", "/route1", 400, 0, 50, Remarks.Missed), + OpenApiCoverageConsoleRow("GET", "/route1", 200, 1, 50, Remarks.Covered, showPath = false, showMethod = false) ) ) } diff --git a/junit5-support/src/test/kotlin/io/specmatic/test/ApiCoverageReportTest.kt b/junit5-support/src/test/kotlin/io/specmatic/test/ApiCoverageReportTest.kt index 99ebaeea0..1138a07eb 100644 --- a/junit5-support/src/test/kotlin/io/specmatic/test/ApiCoverageReportTest.kt +++ b/junit5-support/src/test/kotlin/io/specmatic/test/ApiCoverageReportTest.kt @@ -47,8 +47,8 @@ class ApiCoverageReportTest { assertThat(apiCoverageReport).isEqualTo( OpenAPICoverageConsoleReport( listOf( - OpenApiCoverageConsoleRow("GET", "/order/{id}", 200, 1, 50, Remarks.NotImplemented), - OpenApiCoverageConsoleRow("GET", "/order/{id}", 404, 0, 50, Remarks.Missed, showPath = false, showMethod = false), + OpenApiCoverageConsoleRow("GET", "/order/{id}", 404, 0, 50, Remarks.Missed), + OpenApiCoverageConsoleRow("GET", "/order/{id}", 200, 1, 50, Remarks.NotImplemented, showPath = false, showMethod = false), ), apiCoverageReport.testResultRecords, totalEndpointsCount = 1, missedEndpointsCount = 0, notImplementedAPICount = 0, partiallyMissedEndpointsCount = 1, partiallyNotImplementedAPICount = 1 @@ -71,9 +71,9 @@ class ApiCoverageReportTest { assertThat(apiCoverageReport).isEqualTo( OpenAPICoverageConsoleReport( listOf( - OpenApiCoverageConsoleRow("POST", "/order/{id}", 201, 1, 67, Remarks.NotImplemented), - OpenApiCoverageConsoleRow("POST", "/order/{id}", 400, 1, 67, Remarks.NotImplemented, showPath = false, showMethod = false), - OpenApiCoverageConsoleRow("POST", "/order/{id}", 404, 0, 67, Remarks.Missed, showPath = false, showMethod = false) + OpenApiCoverageConsoleRow("POST", "/order/{id}", 404, 0, 67, Remarks.Missed), + OpenApiCoverageConsoleRow("POST", "/order/{id}", 201, 1, 67, Remarks.NotImplemented, showPath = false, showMethod = false), + OpenApiCoverageConsoleRow("POST", "/order/{id}", 400, 1, 67, Remarks.NotImplemented, showPath = false, showMethod = false) ), apiCoverageReport.testResultRecords, totalEndpointsCount = 1, missedEndpointsCount = 0, notImplementedAPICount = 0, partiallyMissedEndpointsCount = 1, partiallyNotImplementedAPICount = 1 @@ -92,8 +92,8 @@ class ApiCoverageReportTest { assertThat(apiCoverageReport).isEqualTo( OpenAPICoverageConsoleReport( listOf( - OpenApiCoverageConsoleRow("GET", "/order/{id}", 200, 1, 50, Remarks.Covered), - OpenApiCoverageConsoleRow("GET", "/order/{id}", 404, 0, 50, Remarks.Missed, showPath = false, showMethod = false), + OpenApiCoverageConsoleRow("GET", "/order/{id}", 404, 0, 50, Remarks.Missed), + OpenApiCoverageConsoleRow("GET", "/order/{id}", 200, 1, 50, Remarks.Covered, showPath = false, showMethod = false), ), apiCoverageReport.testResultRecords, totalEndpointsCount = 1, missedEndpointsCount = 0, notImplementedAPICount = 0, partiallyMissedEndpointsCount = 1, partiallyNotImplementedAPICount = 0 @@ -115,9 +115,9 @@ class ApiCoverageReportTest { assertThat(apiCoverageReport).isEqualTo( OpenAPICoverageConsoleReport( listOf( - OpenApiCoverageConsoleRow("POST", "/order/{id}", 201, 1, 67, Remarks.Covered), + OpenApiCoverageConsoleRow("POST", "/order/{id}", 404, 0, 67, Remarks.Missed), + OpenApiCoverageConsoleRow("POST", "/order/{id}", 201, 1, 67, Remarks.Covered, showPath = false, showMethod = false), OpenApiCoverageConsoleRow("POST", "/order/{id}", 400, 1, 67, Remarks.Covered, showPath = false, showMethod = false), - OpenApiCoverageConsoleRow("POST", "/order/{id}", 404, 0, 67, Remarks.Missed, showPath = false, showMethod = false) ), apiCoverageReport.testResultRecords, totalEndpointsCount = 1, missedEndpointsCount = 0, notImplementedAPICount = 0, partiallyMissedEndpointsCount = 1, partiallyNotImplementedAPICount = 0 ) ) @@ -328,8 +328,8 @@ class ApiCoverageReportTest { assertThat(apiCoverageReport).isEqualTo( OpenAPICoverageConsoleReport( listOf( - OpenApiCoverageConsoleRow("GET", "/order/{id}", 200, 1, 50, Remarks.Covered), - OpenApiCoverageConsoleRow("GET", "/order/{id}", 404, 0, 50, Remarks.Missed, showPath = false, showMethod = false), + OpenApiCoverageConsoleRow("GET", "/order/{id}", 404, 0, 50, Remarks.Missed), + OpenApiCoverageConsoleRow("GET", "/order/{id}", 200, 1, 50, Remarks.Covered, showPath = false, showMethod = false), ), apiCoverageReport.testResultRecords, totalEndpointsCount = 1, missedEndpointsCount = 0, notImplementedAPICount = 0, partiallyMissedEndpointsCount = 1, partiallyNotImplementedAPICount = 0 ) ) @@ -348,8 +348,8 @@ class ApiCoverageReportTest { assertThat(apiCoverageReport).isEqualTo( OpenAPICoverageConsoleReport( listOf( - OpenApiCoverageConsoleRow("GET", "/order/{id}", 200, 1, 50, Remarks.Covered), - OpenApiCoverageConsoleRow("GET", "/order/{id}", 404, 0, 50, Remarks.Missed, showPath = false, showMethod = false), + OpenApiCoverageConsoleRow("GET", "/order/{id}", 404, 0, 50, Remarks.Missed), + OpenApiCoverageConsoleRow("GET", "/order/{id}", 200, 1, 50, Remarks.Covered, showPath = false, showMethod = false), ), apiCoverageReport.testResultRecords, totalEndpointsCount = 1, missedEndpointsCount = 0, notImplementedAPICount = 0, partiallyMissedEndpointsCount = 1, partiallyNotImplementedAPICount = 0 ) )