diff --git a/a2a-client/build.gradle.kts b/a2a-client/build.gradle.kts index a36df536..8ed64b4c 100644 --- a/a2a-client/build.gradle.kts +++ b/a2a-client/build.gradle.kts @@ -15,11 +15,11 @@ kotlin { sourceSets { commonMain { dependencies { - api(libs.ktor.client.content.negotiation) + api(project(":ai-mocks-a2a-models")) + api(libs.ktor.serialization.kotlinx.json) api(libs.ktor.client.core) api(libs.ktor.client.logging) - api(libs.ktor.serialization.kotlinx.json) - api(project(":ai-mocks-a2a-models")) + api(libs.ktor.client.content.negotiation) api(project.dependencies.platform(libs.ktor.bom)) } } diff --git a/ai-mocks-a2a/build.gradle.kts b/ai-mocks-a2a/build.gradle.kts index 5d519b08..d193cdb4 100644 --- a/ai-mocks-a2a/build.gradle.kts +++ b/ai-mocks-a2a/build.gradle.kts @@ -23,7 +23,6 @@ kotlin { api(libs.ktor.server.content.negotiation) api(project(":ai-mocks-a2a-models")) api(project(":ai-mocks-core")) - api(project.dependencies.platform(libs.ktor.bom)) implementation(libs.ktor.server.sse) } } diff --git a/ai-mocks-a2a/src/jvmTest/kotlin/me/kpavlov/mokksy/utils/StringsTest.kt b/ai-mocks-a2a/src/jvmTest/kotlin/me/kpavlov/mokksy/utils/StringsTest.kt new file mode 100644 index 00000000..309ed55c --- /dev/null +++ b/ai-mocks-a2a/src/jvmTest/kotlin/me/kpavlov/mokksy/utils/StringsTest.kt @@ -0,0 +1,20 @@ +package me.kpavlov.mokksy.utils + +import io.kotest.matchers.shouldBe +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource + +class StringsTest { + @ParameterizedTest + @CsvSource( + ",", + "abcdefghij, abcdefghij", + "abcdefghi, abcdefghi", + "abcdefghijklmnopqrst, abcd...rst", + ) + fun testShortStringUnchanged(input: String?, expected: String?) { + val result = input.ellipsizeMiddle(10) + result shouldBe expected + } + +} diff --git a/ai-mocks-a2a/src/jvmTest/kotlin/me/kpavlov/mokksy/utils/logger/HighlightingTest.kt b/ai-mocks-a2a/src/jvmTest/kotlin/me/kpavlov/mokksy/utils/logger/HighlightingTest.kt new file mode 100644 index 00000000..35f60e59 --- /dev/null +++ b/ai-mocks-a2a/src/jvmTest/kotlin/me/kpavlov/mokksy/utils/logger/HighlightingTest.kt @@ -0,0 +1,77 @@ +package me.kpavlov.mokksy.utils.logger + +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.ktor.http.ContentType +import me.kpavlov.mokksy.utils.logger.Highlighting.highlightBody +import kotlin.test.Test + +internal class HighlightingTest { + + @Test + fun `should highlight JSON key-value pairs with correct colors and retain spaces`() { + // language=json + val input = """ + { + "name" : "Alice", + "age":42, + "active" : true, + "nickname" : null + } + """.trimIndent() + + val result = highlightBody(input, ContentType.Application.Json) + + assertSoftly { + result shouldContain colorize("\"name\"", AnsiColor.MAGENTA) + result shouldContain colorize("\"Alice\"", AnsiColor.GREEN) + + result shouldContain colorize("\"age\"", AnsiColor.MAGENTA) + result shouldContain colorize("42", AnsiColor.BLUE) + + result shouldContain colorize("true", AnsiColor.YELLOW) + + result shouldContain colorize("null", AnsiColor.YELLOW) + + // Check spacing is retained (e.g., double space before/after colon in "name") + result shouldContain "name\"\u001B[0m : " + } + } + + @Test + fun `should highlight form parameters with correct colors and preserve format`() { + val input = "name=Alice&age=30&debug=true" + val result = highlightBody(input, ContentType.Application.FormUrlEncoded) + + assertSoftly { + result shouldContain colorize("name", AnsiColor.YELLOW) + result shouldContain colorize("Alice", AnsiColor.GREEN) + + result shouldContain colorize("age", AnsiColor.YELLOW) + result shouldContain colorize("30", AnsiColor.GREEN) + + result shouldContain colorize("debug", AnsiColor.YELLOW) + result shouldContain colorize("true", AnsiColor.GREEN) + + result.count { it == '&' } shouldBe 2 + } + } + + @Test + fun `should leave invalid pairs untouched`() { + val input = "incomplete&validKey=value" + val result = highlightBody(input, ContentType.Application.FormUrlEncoded) + + assertSoftly { + result shouldContain "incomplete" + result shouldContain colorize("validKey", AnsiColor.YELLOW) + result shouldContain colorize("value", AnsiColor.GREEN) + } + } + + private fun colorize(text: String, color: AnsiColor): String { + return "${color.code}$text\u001B[0m" + } +} + diff --git a/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/responses/OpenaiResponsesMatchers.kt b/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/responses/OpenaiResponsesMatchers.kt index a7f3bb86..1dedf127 100644 --- a/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/responses/OpenaiResponsesMatchers.kt +++ b/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/responses/OpenaiResponsesMatchers.kt @@ -9,6 +9,9 @@ import me.kpavlov.aimocks.openai.model.responses.InputImage import me.kpavlov.aimocks.openai.model.responses.InputItems import me.kpavlov.aimocks.openai.model.responses.InputText import me.kpavlov.aimocks.openai.model.responses.Text +import me.kpavlov.mokksy.utils.ellipsizeMiddle + +private const val IMAGE_URL_MAX_LENGTH = 256 /** * OpenaiResponsesMatchers is a utility object that provides matchers for validating properties of @@ -46,13 +49,22 @@ internal object OpenaiResponsesMatchers { it as? T } + /** + * Returns a matcher that checks whether a `CreateResponseRequest` contains an `InputImage` with the specified URL. + * + * The matcher succeeds if the request's input includes an `InputImage` + * whose `imageUrl` exactly matches the provided value. + * + * @param imageUrl The URL to match against the `InputImage` items in the request. + * @return A Kotest matcher for validating the presence of an image with the given URL. + */ fun containsInputImageWithUrl(imageUrl: String): Matcher = object : Matcher { override fun test(value: CreateResponseRequest?): MatcherResult { val passed = if (value == null) { false - } else if ((value.input is InputItems) == false) { + } else if (value.input !is InputItems) { false } else { val images = extractInputItem(value) @@ -61,21 +73,42 @@ internal object OpenaiResponsesMatchers { return MatcherResult( passed, - { "Input should contain image with url \"$imageUrl\"" }, - { "Input should NOT contain image with url \"$imageUrl\"" }, + { + "Input should contain image with url \"${ + imageUrl.ellipsizeMiddle( + IMAGE_URL_MAX_LENGTH + ) + }\"" + }, + { + "Input should NOT contain image with url \"${ + imageUrl.ellipsizeMiddle( + IMAGE_URL_MAX_LENGTH + ) + }\"" + }, ) } - override fun toString(): String = "InputImage should have URL \"$imageUrl\"" + override fun toString(): String = + "InputImage should have URL \"${imageUrl.ellipsizeMiddle(IMAGE_URL_MAX_LENGTH)}\"" } + /** + * Returns a matcher that checks if a `CreateResponseRequest` contains an input file with the specified filename. + * + * The matcher succeeds if any `InputFile` in the request's input has a filename matching the provided value. + * + * @param filename The name of the file to search for in the request input. + * @return A matcher that verifies the presence of a file with the given filename. + */ fun containsInputFileNamed(filename: String): Matcher = object : Matcher { override fun test(value: CreateResponseRequest?): MatcherResult { val passed = if (value == null) { false - } else if ((value.input is InputItems) == false) { + } else if (value.input !is InputItems) { false } else { val files = extractInputItem(value) @@ -98,7 +131,7 @@ internal object OpenaiResponsesMatchers { val passed = if (value == null) { false - } else if ((value.input is InputItems) == false) { + } else if (value.input !is InputItems) { false } else { val files = extractInputItem(value) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 559e1b54..77c2c3b0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ spotless = "7.2.1" spring = "6.2.9" spring-ai = "1.0.1" system-stubs = "2.1.8" +jansi = "2.4.2" [libraries] anthropic-java = { group = "com.anthropic", name = "anthropic-java", version.ref = "anthropic-java" } @@ -40,6 +41,7 @@ datafaker = { module = "net.datafaker:datafaker", version.ref = "datafaker" } finchly = { module = "me.kpavlov.finchly:finchly", version.ref = "finchly" } google-adk = { group = "com.google.adk", name = "google-adk", version.ref = "google-adk" } google-genai = { group = "com.google.genai", name = "google-genai", version.ref = "google-genai" } +jansi = { module = "org.fusesource.jansi:jansi", version.ref = "jansi" } junit-jupiter-params = { group = "org.junit.jupiter", name = "junit-jupiter-params", version.ref = "junitJupiter" } kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } kotest-assertions-json = { module = "io.kotest:kotest-assertions-json", version.ref = "kotest" } diff --git a/mokksy/build.gradle.kts b/mokksy/build.gradle.kts index a874eed9..73b20130 100644 --- a/mokksy/build.gradle.kts +++ b/mokksy/build.gradle.kts @@ -46,6 +46,7 @@ kotlin { jvmMain { dependencies { api(project.dependencies.platform(libs.netty.bom)) + implementation(libs.jansi) implementation(libs.ktor.server.call.logging) implementation(libs.ktor.server.netty) } diff --git a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/MokksyServer.kt b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/MokksyServer.kt index 29cdd8f9..a2c579af 100644 --- a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/MokksyServer.kt +++ b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/MokksyServer.kt @@ -23,11 +23,24 @@ import kotlinx.coroutines.runBlocking import me.kpavlov.mokksy.request.RequestSpecification import me.kpavlov.mokksy.request.RequestSpecificationBuilder import me.kpavlov.mokksy.request.methodEqual +import me.kpavlov.mokksy.utils.logger.HttpFormatter import java.util.concurrent.ConcurrentSkipListSet import kotlin.reflect.KClass private const val DEFAULT_HOST = "0.0.0.0" +/** + * Creates and returns an embedded Ktor server instance + * with the specified host, port, server configuration, and application module. + * + * This function is platform-specific and must be implemented for each supported target. + * + * @param host The host address to bind the server to. + * @param port The port number to listen on. + * @param configuration The server configuration settings. + * @param module The application module to install in the server. + * @return An embedded server instance configured with the provided parameters. + */ internal expect fun createEmbeddedServer( host: String = DEFAULT_HOST, port: Int, @@ -36,8 +49,15 @@ internal expect fun createEmbeddedServer( ): EmbeddedServer< out ApplicationEngine, out ApplicationEngine.Configuration, -> + > +/** + * Configures content negotiation for the server using the provided configuration. + * + * Platform-specific implementations should install and set up content negotiation plugins as needed. + * + * @param config The content negotiation configuration to apply. + */ internal expect fun configureContentNegotiation(config: ContentNegotiationConfig) public typealias ApplicationConfigurer = (Application.() -> Unit) @@ -54,613 +74,574 @@ public typealias ApplicationConfigurer = (Application.() -> Unit) */ @Suppress("TooManyFunctions") public open class MokksyServer +@JvmOverloads +constructor( + port: Int = 0, + host: String = DEFAULT_HOST, + configuration: ServerConfiguration, + wait: Boolean = false, + configurer: ApplicationConfigurer = {}, +) { + /** + * @constructor Initializes the server with the specified parameters and starts it. + * @param port The port number on which the server will run. Defaults to 0 (randomly assigned port). + * @param verbose A flag indicating whether detailed logs should be printed. Defaults to false. + * @param wait Determines whether the server startup process should block the current thread. + * Defaults to false. + * @param configurer A lambda function for setting custom configurations for the server's application module. + */ @JvmOverloads - constructor( + public constructor( port: Int = 0, host: String = DEFAULT_HOST, - configuration: ServerConfiguration, - wait: Boolean = false, - configurer: ApplicationConfigurer = {}, - ) { - /** - * @constructor Initializes the server with the specified parameters and starts it. - * @param port The port number on which the server will run. Defaults to 0 (randomly assigned port). - * @param verbose A flag indicating whether detailed logs should be printed. Defaults to false. - * @param wait Determines whether the server startup process should block the current thread. - * Defaults to false. - * @param configurer A lambda function for setting custom configurations for the server's application module. - */ - @JvmOverloads - public constructor( - port: Int = 0, - host: String = DEFAULT_HOST, - verbose: Boolean = false, - configurer: (Application) -> Unit = {}, - ) : this( - port = port, + verbose: Boolean = false, + configurer: (Application) -> Unit = {}, + ) : this( + port = port, + host = host, + configuration = ServerConfiguration(verbose = verbose), + wait = false, + configurer = configurer, + ) + + private var resolvedPort: Int + + public lateinit var logger: io.ktor.util.logging.Logger + protected val httpFormatter: HttpFormatter = HttpFormatter() + + private val server = + createEmbeddedServer( host = host, - configuration = ServerConfiguration(verbose = verbose), - wait = false, - configurer = configurer, - ) - - private var resolvedPort: Int - - public lateinit var logger: io.ktor.util.logging.Logger - - private val server = - createEmbeddedServer( - host = host, - port = port, - configuration = configuration, - ) { - install(SSE) - - install(DoubleReceive) - - install(ContentNegotiation) { - configuration.contentNegotiationConfigurer(this) - } + port = port, + configuration = configuration, + ) { + logger = this.environment.log + + install(SSE) + install(DoubleReceive) + install(ContentNegotiation) { + configuration.contentNegotiationConfigurer(this) + } - routing { - route("{...}") { - handle { - handleRequest( - context = this@handle, - application = this@createEmbeddedServer, - stubs = stubs, - configuration = configuration, - ) - } + routing { + route("{...}") { + handle { + handleRequest( + context = this@handle, + application = this@createEmbeddedServer, + stubs = stubs, + configuration = configuration, + formatter = httpFormatter + ) } } - logger = this.environment.log - configurer(this) - } - - private val stubs = ConcurrentSkipListSet>() - - init { - server.start(wait = wait) - runBlocking { - resolvedPort = - server.engine - .resolvedConnectors() - .first() - .port } + configurer(this) } - /** - * Registers a stub in the collection of stubs. - * Ensures that duplicates are not allowed. - * - * @param stub The stub instance to be added to the collection. - */ - private fun registerStub(stub: Stub<*, *>) { - val added = stubs.add(stub) - assert(added) { "Duplicate stub detected: $stub" } - } + private val stubs = ConcurrentSkipListSet>() - /** - * Retrieves the resolved port on which the server is running. - * - * @return The currently configured port number for the server. - */ - public fun port(): Int = resolvedPort - - /** - * Creates a `RequestSpecification` with the specified HTTP method and additional configuration - * defined by the given block, and returns a new `BuildingStep` instance for further customization. - * - * * @param name An optional name assigned to the Stub for identification or debugging purposes. - * @param httpMethod The `HttpMethod` to match for the request specification. - * @param requestType The class type of the request body. - * @param block A lambda used to configure the `RequestSpecificationBuilder`. - * @return A [BuildingStep] instance initialized with the generated request specification. - */ - public fun

method( - configuration: StubConfiguration, - httpMethod: HttpMethod, - requestType: KClass

, - block: RequestSpecificationBuilder

.() -> Unit, - ): BuildingStep

{ - val requestSpec = - RequestSpecificationBuilder(requestType) - .apply(block) - .method(methodEqual(httpMethod)) - .build() - - return BuildingStep( - configuration = configuration, - requestSpecification = requestSpec, - registerStub = this::registerStub, - requestType = requestType, - ) + init { + server.start(wait = wait) + runBlocking { + resolvedPort = + server.engine + .resolvedConnectors() + .first() + .port } + } - /** - * Creates a building step for a stub configuration with the specified parameters. - * - * @param name An optional name for the stub configuration. - * @param httpMethod The HTTP method associated with the request. - * @param requestType The class type of the request payload. - * @param block A block for specifying request details using a RequestSpecificationBuilder. - * @return A BuildingStep instance configured with the provided parameters. - */ - public fun

method( - name: String? = null, - httpMethod: HttpMethod, - requestType: KClass

, - block: RequestSpecificationBuilder

.() -> Unit, - ): BuildingStep

= - method( - configuration = StubConfiguration(name = name), - httpMethod = httpMethod, - requestType = requestType, - block = block, - ) + /** + * Adds a stub to the server's collection, asserting that it is not a duplicate. + * + * @param stub The stub to register. + * @throws AssertionError if the stub is already registered. + */ + private fun registerStub(stub: Stub<*, *>) { + val added = stubs.add(stub) + assert(added) { "Duplicate stub detected: $stub" } + } - /** - * Configures an HTTP GET request specification using the provided block and returns a `BuildingStep` - * instance for further customization. - * - * @param P type of the request payload - * @param requestType The class type of the request body. - * @param block A lambda used to configure the `RequestSpecificationBuilder` for the GET request. - * @return A `BuildingStep` instance initialized with the generated request specification. - */ - public fun

get( - configuration: StubConfiguration, - requestType: KClass

, - block: RequestSpecificationBuilder

.() -> Unit, - ): BuildingStep

= - method( - configuration = configuration, - httpMethod = Get, - requestType = requestType, - block = block, - ) + /** + * Returns the actual port number the server is bound to after startup. + * + * @return The resolved server port. + */ + public fun port(): Int = resolvedPort + + /** + * Creates a request specification for the given HTTP method and request type, + * and returns a building step for further stub configuration. + * + * @param configuration The stub configuration to use for this request specification. + * @param httpMethod The HTTP method to match for incoming requests. + * @param requestType The class type of the expected request body. + * @param block Lambda to configure the request specification builder. + * @return A building step for further customization and stub registration. + */ + public fun

method( + configuration: StubConfiguration, + httpMethod: HttpMethod, + requestType: KClass

, + block: RequestSpecificationBuilder

.() -> Unit, + ): BuildingStep

{ + val requestSpec = + RequestSpecificationBuilder(requestType) + .apply(block) + .method(methodEqual(httpMethod)) + .build() + + return BuildingStep( + configuration = configuration, + requestSpecification = requestSpec, + registerStub = this::registerStub, + requestType = requestType, + ) + } - /** - * Configures an HTTP GET request specification using the provided block and returns a `BuildingStep` - * instance for further customization. - * - * @param P type of the request payload - * @param requestType The class type of the request body. - * @param block A lambda used to configure the `RequestSpecificationBuilder` for the GET request. - * @return A `BuildingStep` instance initialized with the generated request specification. - */ - public fun

get( - name: String? = null, - requestType: KClass

, - block: RequestSpecificationBuilder

.() -> Unit, - ): BuildingStep

= - method( - configuration = StubConfiguration(name), - httpMethod = Get, - requestType = requestType, - block = block, - ) + /** + * Defines a stubbed HTTP request specification for the given method and request type, + * optionally naming the stub. + * + * @param name Optional identifier for the stub. + * @param httpMethod The HTTP method to match (e.g., GET, POST). + * @param requestType The expected type of the request payload. + * @param block Lambda to configure the request specification. + * @return A building step for further stub configuration and registration. + */ + public fun

method( + name: String? = null, + httpMethod: HttpMethod, + requestType: KClass

, + block: RequestSpecificationBuilder

.() -> Unit, + ): BuildingStep

= + method( + configuration = StubConfiguration(name = name), + httpMethod = httpMethod, + requestType = requestType, + block = block, + ) - /** - * Configures HTTP GET request specification using the provided block and returns a `BuildingStep` - * for further customization. This method serves as a convenience shortcut. - * - * @param block A lambda used to configure the `RequestSpecificationBuilder` for the GET request. - * @return A `BuildingStep` instance initialized with the generated request specification. - */ - public fun get( - block: RequestSpecificationBuilder.() -> Unit, - ): BuildingStep = - this.get( - name = null, - requestType = String::class, - block = block, - ) + /** + * Registers a stub for an HTTP GET request with the specified configuration and request type. + * + * @param requestType The class representing the expected request body type. + * @param block Lambda to configure the request specification for the GET request. + * @return A `BuildingStep` for further customization and response definition. + */ + public fun

get( + configuration: StubConfiguration, + requestType: KClass

, + block: RequestSpecificationBuilder

.() -> Unit, + ): BuildingStep

= + method( + configuration = configuration, + httpMethod = Get, + requestType = requestType, + block = block, + ) - /** - * Configures HTTP GET request specification using the provided block and returns a `BuildingStep` - * for further customization. This method serves as a convenience shortcut. - * - * @param configuration The configuration to be used for the request. - * @param block A lambda used to configure the `RequestSpecificationBuilder` for the GET request. - * @return A [BuildingStep] instance initialized with the generated request specification. - */ - public fun get( - configuration: StubConfiguration, - block: RequestSpecificationBuilder.() -> Unit, - ): BuildingStep = - this.get( - configuration = configuration, - requestType = String::class, - block = block, - ) + /** + * Defines a stub for an HTTP GET request with the specified request type and configuration block. + * + * @param name Optional name for the stub, used for identification. + * @param requestType The class of the expected request body. + * @param block Lambda to configure the request specification. + * @return A `BuildingStep` for further stub customization. + */ + public fun

get( + name: String? = null, + requestType: KClass

, + block: RequestSpecificationBuilder

.() -> Unit, + ): BuildingStep

= + method( + configuration = StubConfiguration(name), + httpMethod = Get, + requestType = requestType, + block = block, + ) - /** - * Configures an HTTP POST request specification using the provided block and returns a `BuildingStep` - * instance for further customization. This method uses the HTTP POST method to define the request - * specification within the provided lambda. - * - * @param P type of the request payload. - * @param requestType The class type of the request body. - * @param block A lambda used to configure the `RequestSpecificationBuilder` for the POST request. - * @return A `BuildingStep` instance initialized with the generated request specification. - */ - public fun

post( - name: String? = null, - requestType: KClass

, - block: RequestSpecificationBuilder

.() -> Unit, - ): BuildingStep

= - method( - configuration = StubConfiguration(name), - httpMethod = Post, - requestType = requestType, - block = block, - ) + /** + * Defines a stub for an HTTP GET request with a string body using the provided configuration block. + * + * Returns a `BuildingStep` for further customization of the stubbed GET request. + */ + public fun get( + block: RequestSpecificationBuilder.() -> Unit, + ): BuildingStep = + this.get( + name = null, + requestType = String::class, + block = block, + ) - /** - * Sends a POST request using the provided configuration, - * request type, and block to define the request specification. - * - * @param configuration The configuration settings for the request, including endpoint and other details. - * @param requestType The class type of the request body. - * @param block A lambda function to define the specifics of the request, - * such as headers, query parameters, etc. - * @return A [BuildingStep] instance representing the constructed POST request. - */ - public fun

post( - configuration: StubConfiguration, - requestType: KClass

, - block: RequestSpecificationBuilder

.() -> Unit, - ): BuildingStep

= - method( - configuration = configuration, - httpMethod = Post, - requestType = requestType, - block = block, - ) + /** + * Defines a stub for an HTTP GET request with the specified configuration and request specification builder. + * + * @param configuration The stub configuration for this GET request. + * @param block Lambda to configure the request specification builder. + * @return A [BuildingStep] for further customization of the stub. + */ + public fun get( + configuration: StubConfiguration, + block: RequestSpecificationBuilder.() -> Unit, + ): BuildingStep = + this.get( + configuration = configuration, + requestType = String::class, + block = block, + ) - /** - * Configures an HTTP POST request specification using the provided block and returns a `BuildingStep` - * for further customization. This method serves as a convenience shortcut. - * - * @param block A lambda used to configure the `RequestSpecificationBuilder` for the GET request. - * @return A `BuildingStep` instance initialized with the generated request specification. - */ - public fun post( - block: RequestSpecificationBuilder.() -> Unit, - ): BuildingStep = this.post(name = null, requestType = String::class, block = block) - - /** - * Configures an HTTP DELETE request specification using the provided block and returns a `BuildingStep` - * instance for further customization. This method uses the HTTP DELETE method to define the request - * specification within the provided lambda. - * - * @param P type of the request payload. - * @param requestType The class type of the request body. - * @param block A lambda used to configure the `RequestSpecificationBuilder` for the DELETE request. - * @return A `BuildingStep` instance initialized with the generated request specification. - */ - public fun

delete( - name: String? = null, - requestType: KClass

, - block: RequestSpecificationBuilder

.() -> Unit, - ): BuildingStep

= - method( - configuration = StubConfiguration(name), - httpMethod = Delete, - requestType = requestType, - block = block, - ) + /** + * Defines a stub for an HTTP POST request with the specified request type and configuration block. + * + * @param name Optional name for the stub. + * @param requestType The class of the request body to match. + * @param block Lambda to configure the request specification. + * @return A `BuildingStep` for further stub customization. + */ + public fun

post( + name: String? = null, + requestType: KClass

, + block: RequestSpecificationBuilder

.() -> Unit, + ): BuildingStep

= + method( + configuration = StubConfiguration(name), + httpMethod = Post, + requestType = requestType, + block = block, + ) - /** - * Executes an HTTP DELETE request with the specified configuration and request type. - * - * @param configuration The configuration settings for the request. - * @param requestType The class of the request type that will be processed. - * @param block A lambda to configure the request specification for the DELETE request. - * @return A BuildingStep instance representing the configured DELETE request. - */ - public fun

delete( - configuration: StubConfiguration, - requestType: KClass

, - block: RequestSpecificationBuilder

.() -> Unit, - ): BuildingStep

= - method( - configuration = configuration, - httpMethod = Delete, - requestType = requestType, - block = block, - ) + /** + * Defines a POST request stub with the specified configuration and request type. + * + * @param configuration Stub configuration specifying endpoint and matching criteria. + * @param requestType The class of the expected request body. + * @param block Lambda to configure the request specification details. + * @return A [BuildingStep] for further stub setup or response definition. + */ + public fun

post( + configuration: StubConfiguration, + requestType: KClass

, + block: RequestSpecificationBuilder

.() -> Unit, + ): BuildingStep

= + method( + configuration = configuration, + httpMethod = Post, + requestType = requestType, + block = block, + ) - /** - * Configures an HTTP DELETE request specification using the provided block and returns a `BuildingStep` - * for further customization. This method serves as a convenience shortcut. - * - * @param block A lambda used to configure the `RequestSpecificationBuilder` for the GET request. - * @return A `BuildingStep` instance initialized with the generated request specification. - */ - public fun delete( - block: RequestSpecificationBuilder.() -> Unit, - ): BuildingStep = - this.delete(name = null, requestType = String::class, block = block) - - /** - * Configures an HTTP PATCH request specification using the provided block and returns a `BuildingStep` - * instance for further customization. This method uses the HTTP PATCH method to define the request - * specification within the provided lambda. - * - * @param P type of the request payload. - * @param requestType The class type of the request body. - * @param block A lambda used to configure the `RequestSpecificationBuilder` for the PATCH request. - * @return A `BuildingStep` instance initialized with the generated request specification. - */ - public fun

patch( - name: String? = null, - requestType: KClass

, - block: RequestSpecificationBuilder

.() -> Unit, - ): BuildingStep

= - method( - configuration = StubConfiguration(name), - httpMethod = Patch, - requestType = requestType, - block = block, - ) + /** + * Defines a stub for an HTTP POST request with a string request body. + * + * @param block Lambda to configure the request specification builder. + * @return A building step for further customization of the POST request stub. + */ + public fun post( + block: RequestSpecificationBuilder.() -> Unit, + ): BuildingStep = this.post(name = null, requestType = String::class, block = block) + + /** + * Defines a stub for an HTTP DELETE request with the specified request type and configuration block. + * + * @param name Optional name for the stub. + * @param requestType The class of the request body to match. + * @param block Lambda to configure the request specification. + * @return A `BuildingStep` for further stub customization. + */ + public fun

delete( + name: String? = null, + requestType: KClass

, + block: RequestSpecificationBuilder

.() -> Unit, + ): BuildingStep

= + method( + configuration = StubConfiguration(name), + httpMethod = Delete, + requestType = requestType, + block = block, + ) - /** - * Builds and returns a BuildingStep for a PATCH HTTP request with the provided configuration, request type, - * and custom request specification block. - * - * @param configuration The stub configuration that contains the setup details for the request. - * @param requestType The KClass type of the request body. - * @param block A lambda block to define the request specification. - * @return A [BuildingStep] instance representing the constructed PATCH request. - */ - public fun

patch( - configuration: StubConfiguration, - requestType: KClass

, - block: RequestSpecificationBuilder

.() -> Unit, - ): BuildingStep

= - method( - configuration = configuration, - httpMethod = Patch, - requestType = requestType, - block = block, - ) + /** + * Registers a stub for an HTTP DELETE request with the specified configuration and request type. + * + * @return A BuildingStep for further configuration or response definition of the DELETE request stub. + */ + public fun

delete( + configuration: StubConfiguration, + requestType: KClass

, + block: RequestSpecificationBuilder

.() -> Unit, + ): BuildingStep

= + method( + configuration = configuration, + httpMethod = Delete, + requestType = requestType, + block = block, + ) - /** - * Configures an HTTP PATCH request specification using the provided block and returns a `BuildingStep` - * for further customization. This method serves as a convenience shortcut. - * - * @param block A lambda used to configure the `RequestSpecificationBuilder` for the GET request. - * @return A `BuildingStep` instance initialized with the generated request specification. - */ - public fun patch( - block: RequestSpecificationBuilder.() -> Unit, - ): BuildingStep = - this.patch(name = null, requestType = String::class, block = block) - - /** - * Configures an HTTP PUT request specification using the provided block and returns a `BuildingStep` - * instance for further customization. This method uses the HTTP PUT method to define the request - * specification within the provided lambda. - * - * @param P type of the request payload - * @param block A lambda used to configure the `RequestSpecificationBuilder` for the PUT request. - * @return A `BuildingStep` instance initialized with the generated request specification. - */ - public fun

put( - name: String? = null, - requestType: KClass

, - block: RequestSpecificationBuilder

.() -> Unit, - ): BuildingStep

= - method( - configuration = StubConfiguration(name), - httpMethod = Put, - requestType = requestType, - block = block, - ) + /** + * Defines a stub for an HTTP DELETE request with a string request body. + * + * @param block Lambda to configure the request specification builder. + * @return A building step for further stub customization. + */ + public fun delete( + block: RequestSpecificationBuilder.() -> Unit, + ): BuildingStep = + this.delete(name = null, requestType = String::class, block = block) + + /** + * Defines a stub for an HTTP PATCH request with the specified request type and configuration block. + * + * @param name Optional name for the stub. + * @param requestType The class of the request payload. + * @param block Lambda to configure the request specification. + * @return A building step for further stub customization. + */ + public fun

patch( + name: String? = null, + requestType: KClass

, + block: RequestSpecificationBuilder

.() -> Unit, + ): BuildingStep

= + method( + configuration = StubConfiguration(name), + httpMethod = Patch, + requestType = requestType, + block = block, + ) - /** - * Executes an HTTP PUT request with the provided configuration and request specifications. - * - * @param configuration The stub configuration to be used for the request. - * @param requestType The class type of the request payload. - * @param block A configuration block to build the request specifications. - * @return A [BuildingStep] instance for further processing of the request. - */ - public fun

put( - configuration: StubConfiguration, - requestType: KClass

, - block: RequestSpecificationBuilder

.() -> Unit, - ): BuildingStep

= - method( - configuration = configuration, - httpMethod = Put, - requestType = requestType, - block = block, - ) + /** + * Creates a stub for a PATCH HTTP request with the specified configuration, request type, + * and request specification. + * + * @return A [BuildingStep] for further configuring the PATCH request stub. + */ + public fun

patch( + configuration: StubConfiguration, + requestType: KClass

, + block: RequestSpecificationBuilder

.() -> Unit, + ): BuildingStep

= + method( + configuration = configuration, + httpMethod = Patch, + requestType = requestType, + block = block, + ) - /** - * Configures an HTTP PUT request specification using the provided block and returns a `BuildingStep` - * for further customization. This method serves as a convenience shortcut. - * - * @param block A lambda used to configure the `RequestSpecificationBuilder` for the GET request. - * @return A `BuildingStep` instance initialized with the generated request specification. - */ - public fun put( - block: RequestSpecificationBuilder.() -> Unit, - ): BuildingStep = this.put(name = null, requestType = String::class, block = block) - - /** - * Configures an HTTP HEAD request specification using the provided block and returns a `BuildingStep` - * instance for further customization. This method uses the HTTP HEAD method to define the request - * specification within the provided lambda. - * - * @param P type of the request payload - * @param requestType The class type of the request body. - * @param block A lambda used to configure the `RequestSpecificationBuilder` for the HEAD request. - * @return A `BuildingStep` instance initialized with the generated request specification. - */ - public fun

head( - name: String? = null, - requestType: KClass

, - block: RequestSpecificationBuilder

.() -> Unit, - ): BuildingStep

= - method( - configuration = StubConfiguration(name), - httpMethod = Head, - requestType = requestType, - block = block, - ) + /** + * Defines a PATCH request stub with a string request body using the provided configuration block. + * + * @param block Lambda to configure the request specification builder for the PATCH request. + * @return A `BuildingStep` for further customization of the PATCH request stub. + */ + public fun patch( + block: RequestSpecificationBuilder.() -> Unit, + ): BuildingStep = + this.patch(name = null, requestType = String::class, block = block) + + /** + * Defines a stub for an HTTP PUT request with the specified request type and configuration block. + * + * @param name Optional name for the stub. + * @param requestType The class of the request payload. + * @param block Lambda to configure the request specification. + * @return A `BuildingStep` for further customization of the stub. + */ + public fun

put( + name: String? = null, + requestType: KClass

, + block: RequestSpecificationBuilder

.() -> Unit, + ): BuildingStep

= + method( + configuration = StubConfiguration(name), + httpMethod = Put, + requestType = requestType, + block = block, + ) - /** - * Constructs a HEAD HTTP request with the provided configuration, request type, and specification block. - * - * @param configuration The configuration for the stubbed request. - * @param requestType The class type of the request payload. - * @param block A lambda that specifies the request parameters. - * @return A [BuildingStep] instance representing the constructed request. - */ - public fun

head( - configuration: StubConfiguration, - requestType: KClass

, - block: RequestSpecificationBuilder

.() -> Unit, - ): BuildingStep

= - method( - configuration = configuration, - httpMethod = Head, - requestType = requestType, - block = block, - ) + /** + * Defines a stub for an HTTP PUT request with the specified configuration and request type. + * + * @param configuration The stub configuration for this request. + * @param requestType The class representing the request payload type. + * @param block Lambda to configure the request specification. + * @return A [BuildingStep] for further stub setup. + */ + public fun

put( + configuration: StubConfiguration, + requestType: KClass

, + block: RequestSpecificationBuilder

.() -> Unit, + ): BuildingStep

= + method( + configuration = configuration, + httpMethod = Put, + requestType = requestType, + block = block, + ) - /** - * Configures an HTTP HEAD request specification using the provided block and returns a `BuildingStep` - * for further customization. This method serves as a convenience shortcut. - * - * @param block A lambda used to configure the `RequestSpecificationBuilder` for the GET request. - * @return A `BuildingStep` instance initialized with the generated request specification. - */ - public fun head( - block: RequestSpecificationBuilder.() -> Unit, - ): BuildingStep = this.head(name = null, requestType = String::class, block = block) - - /** - * Configures an HTTP OPTIONS request specification using the provided block and returns a `BuildingStep` - * instance for further customization. This method uses the HTTP OPTIONS method to define the request - * specification within the provided lambda. - * - * @param P type of the request payload - * @param requestType The class type of the request body. - * @param block A lambda used to configure the `RequestSpecificationBuilder` for the OPTIONS request. - * @return A `BuildingStep` instance initialized with the generated request specification. - */ - public fun

options( - name: String? = null, - requestType: KClass

, - block: RequestSpecificationBuilder

.() -> Unit, - ): BuildingStep

= - method( - configuration = StubConfiguration(name), - httpMethod = Options, - requestType = requestType, - block = block, - ) + /** + * Defines a stub for an HTTP PUT request with a string request body. + * + * @param block Lambda to configure the request specification builder. + * @return A building step for further stub customization. + */ + public fun put( + block: RequestSpecificationBuilder.() -> Unit, + ): BuildingStep = this.put(name = null, requestType = String::class, block = block) + + /** + * Defines a stub for an HTTP HEAD request with the specified request type and configuration block. + * + * @param name Optional name for the stub. + * @param requestType The class of the request body. + * @param block Lambda to configure the request specification. + * @return A `BuildingStep` for further customization of the stub. + */ + public fun

head( + name: String? = null, + requestType: KClass

, + block: RequestSpecificationBuilder

.() -> Unit, + ): BuildingStep

= + method( + configuration = StubConfiguration(name), + httpMethod = Head, + requestType = requestType, + block = block, + ) - /** - * Configures and executes an HTTP OPTIONS request based on the given parameters. - * - * @param configuration The configuration settings to use for the request. - * @param requestType The class type of the request body. - * @param block A lambda to build and modify the request specifications. - * @return A [BuildingStep] object representing the state after configuring the OPTIONS request. - */ - public fun

options( - configuration: StubConfiguration, - requestType: KClass

, - block: RequestSpecificationBuilder

.() -> Unit, - ): BuildingStep

= - method( - configuration = configuration, - httpMethod = Options, - requestType = requestType, - block = block, - ) + /** + * Defines a stub for a HEAD HTTP request with the specified configuration and request type. + * + * @param configuration The stub configuration for this request. + * @param requestType The class representing the request payload type. + * @param block Lambda to configure the request specification. + * @return A [BuildingStep] for further stub setup. + */ + public fun

head( + configuration: StubConfiguration, + requestType: KClass

, + block: RequestSpecificationBuilder

.() -> Unit, + ): BuildingStep

= + method( + configuration = configuration, + httpMethod = Head, + requestType = requestType, + block = block, + ) - /** - * Configures an HTTP HEAD request specification using the provided block and returns a `BuildingStep` - * for further customization. This method serves as a convenience shortcut. - * - * @param block A lambda used to configure the `RequestSpecificationBuilder` for the GET request. - * @return A `BuildingStep` instance initialized with the generated request specification. - */ - public fun options( - block: RequestSpecificationBuilder.() -> Unit, - ): BuildingStep = - this.options(name = null, requestType = String::class, block = block) - - /** - * Retrieves a list of all request specifications that have not been matched to any incoming requests. - * A request is considered unmatched if its match count is zero. - * - * @return A list of unmatched request specifications. - */ - public fun findAllUnmatchedRequests(): List> = - stubs - .filter { - it.matchCount() == 0 - }.map { it.requestSpecification } - .toList() - - /** - * Resets the match counts for all mappings in the server. Each mapping's match count - * is set to zero, effectively clearing any record of previous matches. - * - * This is useful for resetting the state of the server when reinitializing or performing - * testing scenarios. - */ - public fun resetMatchCounts() { - stubs - .forEach { - it.resetMatchCount() - } - } + /** + * Defines a stub for an HTTP HEAD request with a string request body. + * + * @param block Lambda to configure the request specification builder. + * @return A building step for further customization of the HEAD request stub. + */ + public fun head( + block: RequestSpecificationBuilder.() -> Unit, + ): BuildingStep = this.head(name = null, requestType = String::class, block = block) + + /** + * Defines a stub for an HTTP OPTIONS request with the specified request type and configuration block. + * + * @param name Optional name for the stub. + * @param requestType The class of the request payload. + * @param block Lambda to configure the request specification. + * @return A `BuildingStep` for further customization of the stub. + */ + public fun

options( + name: String? = null, + requestType: KClass

, + block: RequestSpecificationBuilder

.() -> Unit, + ): BuildingStep

= + method( + configuration = StubConfiguration(name), + httpMethod = Options, + requestType = requestType, + block = block, + ) + + /** + * Defines a stub for an HTTP OPTIONS request with the specified configuration and request type. + * + * @param configuration Stub configuration settings for this request. + * @param requestType The class representing the expected request body type. + * @param block Lambda to configure the request specification. + * @return A [BuildingStep] for further stub setup or response definition. + */ + public fun

options( + configuration: StubConfiguration, + requestType: KClass

, + block: RequestSpecificationBuilder

.() -> Unit, + ): BuildingStep

= + method( + configuration = configuration, + httpMethod = Options, + requestType = requestType, + block = block, + ) - /** - * Checks for any unmatched requests by retrieving all request specifications that - * have not been matched to incoming requests and verifies that the list of unmatched - * requests is empty. - * - * This method ensures that all request mappings have been used as expected. If - * there are unmatched requests, this would indicate that some defined mappings were - * not triggered during the testing or execution process. - * - * Uses the `findAllUnmatchedRequests` function to identify unmatched requests and - * asserts that no unmatched requests exist. - */ - public fun checkForUnmatchedRequests() { - val unmatchedRequests = findAllUnmatchedRequests() - if (unmatchedRequests.isNotEmpty()) { - failure( - "The following requests were not matched: ${ - unmatchedRequests.joinToString { - it - .toLogString() - } - }", - ) + /** + * Defines an HTTP OPTIONS request stub with a string request body using the provided configuration block. + * + * @param block Lambda to configure the request specification builder for the OPTIONS request. + * @return A `BuildingStep` for further customization of the stub. + */ + public fun options( + block: RequestSpecificationBuilder.() -> Unit, + ): BuildingStep = + this.options(name = null, requestType = String::class, block = block) + + /** + * Returns all request specifications that have not matched any incoming requests. + * + * A request specification is considered unmatched if its associated stub's match count is zero. + * + * @return A list of unmatched request specifications. + */ + public fun findAllUnmatchedRequests(): List> = + stubs + .filter { + it.matchCount() == 0 + }.map { it.requestSpecification } + .toList() + + /** + * Resets the match count of all registered stubs to zero. + * + * Use this to clear match history before running new tests or scenarios. + */ + public fun resetMatchCounts() { + stubs + .forEach { + it.resetMatchCount() } - } + } - /** - * Shuts down the server by stopping its execution. - * - * This method halts any ongoing operations of the server, - * effectively terminating its current state and releasing any occupied resources. - * It should be invoked to safely stop the server when it is no longer necessary. - */ - public fun shutdown() { - server.stop() + /** + * Verifies that all registered request stubs have been matched at least once. + * + * Throws an error if any request specification was not triggered during execution, listing all unmatched requests. + */ + public fun checkForUnmatchedRequests() { + val unmatchedRequests = findAllUnmatchedRequests() + if (unmatchedRequests.isNotEmpty()) { + failure( + "The following requests were not matched: ${ + unmatchedRequests.joinToString { + it + .toLogString() + } + }", + ) } } + + /** + * Stops the embedded server and releases its resources. + * + * Call this method to terminate the server when it is no longer needed. + */ + public fun shutdown() { + server.stop() + } +} diff --git a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/RequestHandler.kt b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/RequestHandler.kt index d8d788af..0d2b9515 100644 --- a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/RequestHandler.kt +++ b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/RequestHandler.kt @@ -3,32 +3,30 @@ package me.kpavlov.mokksy import io.kotest.assertions.failure import io.ktor.server.application.Application import io.ktor.server.application.log -import io.ktor.server.logging.toLogString -import io.ktor.server.request.httpMethod -import io.ktor.server.request.receive -import io.ktor.server.request.uri import io.ktor.server.routing.RoutingContext import io.ktor.server.routing.RoutingRequest +import me.kpavlov.mokksy.utils.logger.HttpFormatter /** - * Handles an incoming HTTP request by identifying the appropriate mapping based on the request - * parameters, then processes and sends the response accordingly. If no mapping is matched, - * logs a failure. + * Processes an incoming HTTP request by matching it against available stubs and handling the response. * - * @param context The routing context containing the request and response handlers. - * @param application The Ktor application instance used for logging and other application-level operations. - * @param stubs A collection of mappings that specify how incoming requests should be processed and responded to. + * Attempts to find the best matching stub for the request. + * If a match is found, processes the stub and optionally removes it based on configuration. + * If no match is found, logs the event and triggers a failure. + * + * @param context The routing context containing the request and response. + * @param stubs The set of available stubs to match against. + * @param configuration Server configuration settings that influence matching and logging behavior. + * @param formatter Formats HTTP requests for logging and error messages. */ internal suspend fun handleRequest( context: RoutingContext, application: Application, stubs: MutableSet>, configuration: ServerConfiguration, + formatter: HttpFormatter ) { val request = context.call.request - application.log.info( - "Request: ${request.toLogString()}", - ) val matchedStub: Stub<*, *>? = stubs .filter { stub -> @@ -39,9 +37,9 @@ internal suspend fun handleRequest( if (configuration.verbose) { application.log.warn( "Failed to evaluate condition for stub:\n---\n{}\n---" + - "\nand request:\n---\n{}\n---", + "\nand request:\n---\n{}---", stub.toLogString(), - printRequest(request), + formatter.formatRequest(request), it, ) } @@ -66,22 +64,30 @@ internal suspend fun handleRequest( application = application, request = request, context = context, + formatter = formatter ) } else { if (configuration.verbose) { application.log.warn( - "No stubs found for request:\n---\n${printRequest(request)}\n---\nAvailable stubs:\n{}\n", + "No stubs found for request:\n---\n${formatter.formatRequest(request)}\n---\nAvailable stubs:\n{}\n", stubs.joinToString("\n---\n") { it.toLogString() }, ) } else { application.log.warn( - "No matched mapping for request:\n---\n${printRequest(request)}\n---", + "No matched mapping for request:\n---\n${formatter.formatRequest(request)}\n---", ) } - failure("No matched mapping for request: ${printRequest(request)}") + failure("No matched mapping for request: ${formatter.formatRequest(request)}") } } +/** + * Processes a matched stub by logging the match, incrementing its match count, + * and sending the stubbed response. + * + * If verbose logging is enabled in either the server or stub configuration, + * logs detailed information about the matched request and stub. + */ @Suppress("LongParameterList") private suspend fun handleMatchedStub( matchedStub: Stub<*, *>, @@ -89,6 +95,7 @@ private suspend fun handleMatchedStub( application: Application, request: RoutingRequest, context: RoutingContext, + formatter: HttpFormatter ) { val config = matchedStub.configuration val verbose = serverConfig.verbose || config.verbose @@ -96,7 +103,7 @@ private suspend fun handleMatchedStub( matchedStub.apply { if (verbose) { application.log.info( - "Request matched:\n---\n${printRequest(request)}\n---\nStub: {}", + "Request matched:\n---\n${formatter.formatRequest(request)}---\n{}", this.toLogString(), ) } @@ -104,13 +111,3 @@ private suspend fun handleMatchedStub( respond(context.call, verbose) } } - -private suspend fun printRequest(request: RoutingRequest): String { - val body = request.call.receive(String::class) - return """ - |${request.httpMethod} ${request.uri} - |${request.headers.entries().joinToString("\n") { "${it.key}: ${it.value}" }} - | - |$body - """.trimMargin() -} diff --git a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/utils/Strings.kt b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/utils/Strings.kt new file mode 100644 index 00000000..22dcdb4f --- /dev/null +++ b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/utils/Strings.kt @@ -0,0 +1,18 @@ +package me.kpavlov.mokksy.utils + +/** + * Truncates the string to the specified maximum length by replacing the middle portion with an ellipsis. + * + * If the string is `null`, its length is less than or equal to `maxLength`, or `maxLength` is less than 5, the original string is returned unchanged. Otherwise, the string is shortened by keeping the start and end segments and inserting "..." in the middle so that the total length does not exceed `maxLength`. + * + * @param maxLength The maximum allowed length of the resulting string, including the ellipsis. Must be at least 5 to perform truncation. + * @return The original string if no truncation is needed, or a new string with the middle replaced by an ellipsis if truncation occurs. + */ +public fun String?.ellipsizeMiddle(maxLength: Int): String? { + if (this == null || this.length <= maxLength || maxLength < 5) return this + + val half = (maxLength - 3) / 2 + val start = this.take(half + (maxLength - 3) % 2) // Adjust for odd maxLength + val end = this.takeLast(half) + return "$start...$end" +} diff --git a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/utils/logger/Highlighting.kt b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/utils/logger/Highlighting.kt new file mode 100644 index 00000000..033591cf --- /dev/null +++ b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/utils/logger/Highlighting.kt @@ -0,0 +1,84 @@ +package me.kpavlov.mokksy.utils.logger + +import io.ktor.http.ContentType + +public object Highlighting { + /** + * Applies ANSI color highlighting to an HTTP body string based on its content type. + * + * For JSON content, syntax highlighting is applied to keys and values. For form URL-encoded data, keys and values are colored distinctly. Other content types are displayed in light gray. + * + * @param body The HTTP body content to highlight. + * @param contentType The content type of the body. + * @return The highlighted body string with ANSI color codes. + */ + public fun highlightBody(body: String, contentType: ContentType): String { + return when { + contentType.match(ContentType.Application.Json) -> highlightJson(body) + contentType.match(ContentType.Application.FormUrlEncoded) -> highlightForm(body) + else -> colorize(body, AnsiColor.LIGHT_GRAY) + } + } + + /** + * Applies ANSI color highlighting to a JSON string for terminal output. + * + * Keys are colored magenta, string values green, numeric values blue, and boolean/null values yellow. + * + * @param json The JSON string to highlight. + * @return The JSON string with ANSI color codes applied for syntax highlighting. + */ + private fun highlightJson(json: String): String { + val keyColor = AnsiColor.MAGENTA + val stringValColor = AnsiColor.GREEN + val numberValColor = AnsiColor.BLUE + val boolNullColor = AnsiColor.YELLOW + + val regex = Regex( + "\"(.*?)\"(\\s*):(\\s*)(\".*?\"|\\d+(\\.\\d+)?|true|false|null)", + RegexOption.DOT_MATCHES_ALL + ) + + return regex.replace(json) { match -> + val key = match.groupValues[1] + val spaceBeforeColon = match.groupValues[2] + val spaceAfterColon = match.groupValues[3] + val value = match.groupValues[4] + + val coloredKey = colorize("\"$key\"", keyColor) + + val coloredValue = when { + value.startsWith("\"") -> colorize(value, stringValColor) + value == "true" || value == "false" || value == "null" -> colorize( + value, + boolNullColor + ) + + else -> colorize(value, numberValColor) + } + + "$coloredKey$spaceBeforeColon:$spaceAfterColon$coloredValue" + } + } + + /** + * Applies ANSI color highlighting to URL-encoded form data. + * + * Splits the input string into key-value pairs separated by '&' and colors keys in yellow and values in green. + * Pairs that do not contain exactly one '=' are left unchanged. + * + * @param data The URL-encoded form data to highlight. + * @return The highlighted form data as a string with ANSI color codes. + */ + private fun highlightForm(data: String): String { + return data.split("&").joinToString("&") { + val parts = it.split("=") + if (parts.size == 2) { + val key = colorize(parts[0], AnsiColor.YELLOW) + val value = colorize(parts[1], AnsiColor.GREEN) + "$key=$value" + } else it + } + } +} + diff --git a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/utils/logger/HttpFormatter.kt b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/utils/logger/HttpFormatter.kt new file mode 100644 index 00000000..bdb758da --- /dev/null +++ b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/utils/logger/HttpFormatter.kt @@ -0,0 +1,165 @@ +package me.kpavlov.mokksy.utils.logger + +import io.ktor.http.ContentType +import io.ktor.http.HttpMethod +import io.ktor.server.request.contentType +import io.ktor.server.request.httpMethod +import io.ktor.server.request.receiveText +import io.ktor.server.request.uri +import io.ktor.server.routing.RoutingRequest +import me.kpavlov.mokksy.utils.logger.Highlighting.highlightBody + + +public enum class ColorTheme { LIGHT_ON_DARK, DARK_ON_LIGHT } + +/** + * Determines whether ANSI color output is supported on the current platform. + * + * @return `true` if ANSI color codes can be used for output; otherwise, `false`. + */ +internal expect fun isColorSupported(): Boolean + +public enum class AnsiColor(public val code: String) { + RESET("\u001B[0m"), + STRONGER("\u001B[1m"), + PALE("\u001B[2m"), + BLACK("\u001B[30m"), + RED("\u001B[31m"), + GREEN("\u001B[32m"), + YELLOW("\u001B[33m"), + BLUE("\u001B[34m"), + MAGENTA("\u001B[35m"), + CYAN("\u001B[36m"), + WHITE("\u001B[37m"), + LIGHT_GRAY("\u001B[37m"), + LIGHT_GRAY_BOLD("\u001B[37;1m"), + DARK_GRAY("\u001B[90m"), +} + +/** + * Wraps the given text with the specified ANSI color code if coloring is enabled. + * + * @param text The text to colorize. + * @param color The ANSI color to apply. + * @param enabled Whether to apply colorization; if false, returns the original text. + * @return The colorized text if enabled, otherwise the original text. + */ +internal fun colorize(text: String, color: AnsiColor, enabled: Boolean = true): String { + return if (enabled) "${color.code}$text${AnsiColor.RESET.code}" else text +} + +public open class HttpFormatter( + theme: ColorTheme = ColorTheme.LIGHT_ON_DARK, + protected val useColor: Boolean = isColorSupported(), +) { + + /** + * Returns the HTTP method name colorized according to its type and the current color settings. + * + * GET, POST, and DELETE methods are assigned specific colors; other methods are rendered in bold. + */ + private fun method(method: HttpMethod): String { + val color = when (method) { + HttpMethod.Get -> AnsiColor.BLUE + HttpMethod.Post -> AnsiColor.GREEN + HttpMethod.Delete -> AnsiColor.RED + else -> AnsiColor.STRONGER + } + return colorize(method.value, color, useColor) + } + + protected val colors: ColorScheme = when (theme) { + ColorTheme.LIGHT_ON_DARK -> ColorScheme( + path = AnsiColor.STRONGER, + headerName = AnsiColor.YELLOW, + headerValue = AnsiColor.PALE, + body = AnsiColor.LIGHT_GRAY + ) + + ColorTheme.DARK_ON_LIGHT -> ColorScheme( + path = AnsiColor.STRONGER, + headerName = AnsiColor.BLACK, + headerValue = AnsiColor.PALE, + body = AnsiColor.LIGHT_GRAY + ) + } + + /** + * Formats an HTTP request line with the method and path, applying colorization based on the selected theme. + * + * @param method The HTTP method to display. + * @param path The request path to display. + * @return The formatted and optionally colorized request line. + */ + public fun requestLine(method: HttpMethod, path: String): String = + "${method(method)} ${ + colorize( + path, + colors.path, + useColor + ) + }" + + /** + * Formats an HTTP header line with colorized header name and values. + * + * @param k The header name. + * @param values The list of header values. + * @return The formatted and colorized header line as a string. + */ + public fun header(k: String, values: List): String = + "${colorize(k, colors.headerName, useColor)}: ${ + colorize( + values.joinToString(separator = ",", prefix = "[", postfix = "]"), + colors.headerValue, + useColor + ) + }" + + /** + * Formats the HTTP request body, applying syntax highlighting if color output is enabled. + * + * Returns an empty string if the body is null or blank. If color output is enabled, the body is highlighted according to its content type; otherwise, the raw body string is returned. + * + * @param body The HTTP request body to format. + * @param contentType The content type of the body, used for syntax highlighting. + * @return The formatted body string, or an empty string if the body is null or blank. + */ + public fun formatBody(body: String?, contentType: ContentType = ContentType.Any): String { + if (body.isNullOrBlank()) return "" + return if (useColor) highlightBody(body, contentType) else body + } + + /** + * Formats an HTTP request into a colorized, multi-line string representation. + * + * The output includes the request line, all headers, and the request body, with color highlighting applied according to the formatter's theme and color settings. + * + * @param request The HTTP routing request to format. + * @return A formatted string representing the full HTTP request. + */ + internal suspend fun formatRequest(request: RoutingRequest): String { + val body = request.call.receiveText() + return buildString { + appendLine(requestLine(request.httpMethod, request.uri)) + request.headers.entries().forEach { + appendLine(header(it.key, it.value)) + } + appendLine() + appendLine(formatBody(body, request.contentType())) + } + } + + + public data class ColorScheme( + val path: AnsiColor, + val headerName: AnsiColor, + val headerValue: AnsiColor, + val body: AnsiColor + ) +} + + + + + diff --git a/mokksy/src/commonTest/kotlin/me/kpavlov/mokksy/MokksyServerTest.kt b/mokksy/src/commonTest/kotlin/me/kpavlov/mokksy/MokksyServerTest.kt new file mode 100644 index 00000000..125df568 --- /dev/null +++ b/mokksy/src/commonTest/kotlin/me/kpavlov/mokksy/MokksyServerTest.kt @@ -0,0 +1,683 @@ +package me.kpavlov.mokksy + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.ints.shouldBeGreaterThan +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.ktor.http.HttpMethod +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Comprehensive unit tests for MokksyServer class. + * Testing framework: kotlin.test with kotest assertions + * Focus: Server lifecycle, HTTP method handling, configuration, and error scenarios + */ +class MokksyServerTest { + + @Test + fun `should create server with default parameters`() { + val server = MokksyServer() + + // Server should be created successfully + assertNotNull(server) + server.port() shouldBeGreaterThan 0 + + server.shutdown() + } + + @Test + fun `should create server with custom port`() { + val server = MokksyServer(port = 0) // Use 0 for dynamic port assignment + + // Should assign an available port + server.port() shouldBeGreaterThan 0 + + server.shutdown() + } + + @Test + fun `should create server with verbose logging enabled`() { + val server = MokksyServer(verbose = true) + + assertNotNull(server) + server.port() shouldBeGreaterThan 0 + + server.shutdown() + } + + @Test + fun `should create server with custom configuration`() { + val config = ServerConfiguration(verbose = true) + val server = MokksyServer(configuration = config) + + assertNotNull(server) + server.port() shouldBeGreaterThan 0 + + server.shutdown() + } + + @Test + fun `should create server with custom host`() { + val server = MokksyServer(host = "127.0.0.1") + + assertNotNull(server) + server.port() shouldBeGreaterThan 0 + + server.shutdown() + } + + @Test + fun `should register GET stub successfully`() { + val server = MokksyServer() + + val buildingStep = server.get { + path("/test") + } + + assertNotNull(buildingStep) + server.shutdown() + } + + @Test + fun `should register POST stub successfully`() { + val server = MokksyServer() + + val buildingStep = server.post { + path("/api/data") + } + + assertNotNull(buildingStep) + server.shutdown() + } + + @Test + fun `should register PUT stub successfully`() { + val server = MokksyServer() + + val buildingStep = server.put { + path("/api/update") + } + + assertNotNull(buildingStep) + server.shutdown() + } + + @Test + fun `should register DELETE stub successfully`() { + val server = MokksyServer() + + val buildingStep = server.delete { + path("/api/remove") + } + + assertNotNull(buildingStep) + server.shutdown() + } + + @Test + fun `should register PATCH stub successfully`() { + val server = MokksyServer() + + val buildingStep = server.patch { + path("/api/modify") + } + + assertNotNull(buildingStep) + server.shutdown() + } + + @Test + fun `should register HEAD stub successfully`() { + val server = MokksyServer() + + val buildingStep = server.head { + path("/api/check") + } + + assertNotNull(buildingStep) + server.shutdown() + } + + @Test + fun `should register OPTIONS stub successfully`() { + val server = MokksyServer() + + val buildingStep = server.options { + path("/api/options") + } + + assertNotNull(buildingStep) + server.shutdown() + } + + @Test + fun `should register stub with custom HTTP method`() { + val server = MokksyServer() + + val buildingStep = server.method( + httpMethod = HttpMethod.Get, + requestType = String::class + ) { + path("/custom") + } + + assertNotNull(buildingStep) + server.shutdown() + } + + @Test + fun `should register named stub`() { + val server = MokksyServer() + + val buildingStep = server.get(name = "test-stub") { + path("/named") + } + + assertNotNull(buildingStep) + server.shutdown() + } + + @Test + fun `should register stub with configuration`() { + val server = MokksyServer() + val config = StubConfiguration(name = "configured-stub") + + val buildingStep = server.get(configuration = config) { + path("/configured") + } + + assertNotNull(buildingStep) + server.shutdown() + } + + @Test + fun `should register stub with custom request type`() { + val server = MokksyServer() + + data class CustomRequest(val id: Int, val name: String) + + val buildingStep = server.post(requestType = CustomRequest::class) { + path("/custom-type") + } + + assertNotNull(buildingStep) + server.shutdown() + } + + @Test + fun `should handle multiple stubs registration`() { + val server = MokksyServer() + + server.get { path("/endpoint1") } + server.post { path("/endpoint2") } + server.put { path("/endpoint3") } + server.delete { path("/endpoint4") } + server.patch { path("/endpoint5") } + server.head { path("/endpoint6") } + server.options { path("/endpoint7") } + + // Should not throw any exceptions during registration + assertEquals(7, server.findAllUnmatchedRequests().size) + server.shutdown() + } + + @Test + fun `should initialize unmatched requests list as empty when no stubs registered`() { + val server = MokksyServer() + + val unmatchedRequests = server.findAllUnmatchedRequests() + + unmatchedRequests.shouldBeEmpty() + server.shutdown() + } + + @Test + fun `should track unmatched requests`() { + val server = MokksyServer() + + // Register a stub that won't be matched + server.get { path("/never-called") } + + val unmatchedRequests = server.findAllUnmatchedRequests() + + unmatchedRequests.shouldNotBeEmpty() + assertEquals(1, unmatchedRequests.size) + server.shutdown() + } + + @Test + fun `should reset match counts`() { + val server = MokksyServer() + + server.get { path("/test-reset") } + + // Initially unmatched + server.findAllUnmatchedRequests().shouldNotBeEmpty() + + // Reset should still show unmatched since no actual requests were made + server.resetMatchCounts() + server.findAllUnmatchedRequests().shouldNotBeEmpty() + + server.shutdown() + } + + @Test + fun `should check for unmatched requests and throw when present`() { + val server = MokksyServer() + + // Register stub that won't be matched + server.get { path("/unmatched-endpoint") } + + // Should throw because of unmatched requests + shouldThrow { + server.checkForUnmatchedRequests() + } + + server.shutdown() + } + + @Test + fun `should check for unmatched requests and pass when none present`() { + val server = MokksyServer() + + // No stubs registered, so no unmatched requests + server.checkForUnmatchedRequests() // Should not throw + + server.shutdown() + } + + @Test + fun `should handle concurrent stub registrations`() = runTest { + val server = MokksyServer() + + // Register multiple stubs concurrently + val stubRegistrations = (1..10).map { index -> + async { + server.get { path("/concurrent-$index") } + } + } + + // Wait for all registrations to complete + stubRegistrations.awaitAll() + + // Should have all unmatched requests + val unmatchedRequests = server.findAllUnmatchedRequests() + assertEquals(10, unmatchedRequests.size) + + server.shutdown() + } + + @Test + fun `should handle server lifecycle correctly`() { + var server: MokksyServer? = null + + try { + server = MokksyServer() + val port = server.port() + + port shouldBeGreaterThan 0 + + // Server should be functional after creation + server.get { path("/lifecycle-test") } + + } finally { + server?.shutdown() + } + } + + @Test + fun `should handle multiple shutdowns gracefully`() { + val server = MokksyServer() + + // First shutdown + server.shutdown() + + // Second shutdown should not throw + server.shutdown() + } + + @Test + fun `should register stub with response definition`() { + val server = MokksyServer() + + server.get { path("/with-response") } respondsWith { + status(200) + body("OK") + } + + server.shutdown() + } + + @Test + fun `should register stub with streaming response`() { + val server = MokksyServer() + + server.get { path("/stream") } respondsWithStream { + // Stream configuration would go here + } + + server.shutdown() + } + + @Test + fun `should register stub with SSE response`() { + val server = MokksyServer() + + server.get { path("/sse") } respondsWithSseStream { + // SSE stream configuration would go here + } + + server.shutdown() + } + + @Test + fun `should handle custom configurer during construction`() { + val server = MokksyServer { application -> + // Custom application configuration + application.environment.log.info("Custom configurer applied") + } + + assertNotNull(server) + server.shutdown() + } + + @Test + fun `should handle port assignment with zero port`() { + val server = MokksyServer(port = 0) // Should assign random available port + + server.port() shouldBeGreaterThan 0 + server.shutdown() + } + + @Test + fun `should preserve stub configuration names`() { + val server = MokksyServer() + + server.get(name = "named-get-stub") { path("/named-get") } + server.post(name = "named-post-stub") { path("/named-post") } + + // Verify stubs are registered (indirectly through unmatched requests) + val unmatchedRequests = server.findAllUnmatchedRequests() + assertEquals(2, unmatchedRequests.size) + + server.shutdown() + } + + @Test + fun `should handle complex request specifications`() { + val server = MokksyServer() + + server.post { + path("/complex") + header("Content-Type", "application/json") + header("Authorization", "Bearer token") + } respondsWith { + status(201) + header("Location", "/resource/123") + body("""{"created": true}""") + } + + server.shutdown() + } + + @Test + fun `should support fluent API chaining`() { + val server = MokksyServer() + + // Test fluent API + val result = server + .get { path("/fluent") } + .respondsWith { + status(200) + body("Fluent API works") + } + + // The fluent API should complete without returning a value (Unit) + assertEquals(Unit, result) + + server.shutdown() + } + + @Test + fun `should handle edge case request types`() { + val server = MokksyServer() + + // Test with different request types + server.post(requestType = Map::class) { path("/map-request") } + server.put(requestType = List::class) { path("/list-request") } + server.patch(requestType = Any::class) { path("/any-request") } + + assertEquals(3, server.findAllUnmatchedRequests().size) + + server.shutdown() + } + + @Test + fun `should handle server with wait parameter`() { + val server = MokksyServer(wait = false) // Non-blocking start + + server.port() shouldBeGreaterThan 0 + server.shutdown() + } + + @Test + fun `should maintain logger instance`() { + val server = MokksyServer() + + // Logger should be initialized after server creation + assertNotNull(server.logger) + + server.shutdown() + } + + @Test + fun `should handle rapid stub registration and removal cycle`() { + repeat(5) { cycle -> + val server = MokksyServer() + + // Register multiple stubs + repeat(3) { stubIndex -> + server.get { path("/cycle-$cycle-stub-$stubIndex") } + } + + // Verify registration + assertEquals(3, server.findAllUnmatchedRequests().size) + + server.shutdown() + } + } + + @Test + fun `should handle all HTTP method variants`() { + val server = MokksyServer() + + // Test all HTTP method convenience functions + server.get(requestType = String::class) { path("/get-typed") } + server.post(requestType = String::class) { path("/post-typed") } + server.put(requestType = String::class) { path("/put-typed") } + server.delete(requestType = String::class) { path("/delete-typed") } + server.patch(requestType = String::class) { path("/patch-typed") } + server.head(requestType = String::class) { path("/head-typed") } + server.options(requestType = String::class) { path("/options-typed") } + + assertEquals(7, server.findAllUnmatchedRequests().size) + + server.shutdown() + } + + @Test + fun `should handle method with configuration variants`() { + val server = MokksyServer() + val config = StubConfiguration(name = "config-test") + + // Test method variants with configuration + server.get(configuration = config, requestType = String::class) { path("/get-config") } + server.post(configuration = config, requestType = String::class) { path("/post-config") } + server.put(configuration = config, requestType = String::class) { path("/put-config") } + server.delete(configuration = config, requestType = String::class) { path("/delete-config") } + server.patch(configuration = config, requestType = String::class) { path("/patch-config") } + server.head(configuration = config, requestType = String::class) { path("/head-config") } + server.options(configuration = config, requestType = String::class) { path("/options-config") } + + assertEquals(7, server.findAllUnmatchedRequests().size) + + server.shutdown() + } + + @Test + fun `should handle string-based stubs without explicit type`() { + val server = MokksyServer() + + // These use the convenience methods that default to String::class + server.get { path("/string-get") } + server.post { path("/string-post") } + server.put { path("/string-put") } + server.delete { path("/string-delete") } + server.patch { path("/string-patch") } + server.head { path("/string-head") } + server.options { path("/string-options") } + + assertEquals(7, server.findAllUnmatchedRequests().size) + + server.shutdown() + } + + @Test + fun `should handle custom request types with data classes`() { + val server = MokksyServer() + + data class User(val id: Long, val name: String, val email: String) + data class Product(val sku: String, val name: String, val price: Double) + + server.post(requestType = User::class) { path("/users") } + server.put(requestType = Product::class) { path("/products") } + + assertEquals(2, server.findAllUnmatchedRequests().size) + + server.shutdown() + } + + @Test + fun `should handle request specifications with multiple matchers`() { + val server = MokksyServer() + + server.post { + path("/api/users") + header("Content-Type", "application/json") + header("Accept", "application/json") + queryParameter("version", "v1") + } respondsWith { + status(201) + body("""{"id": 123, "created": true}""") + } + + assertEquals(1, server.findAllUnmatchedRequests().size) + + server.shutdown() + } + + @Test + fun `should handle response with explicit type specification`() { + val server = MokksyServer() + + data class ResponseData(val message: String, val timestamp: Long) + + server.get { path("/typed-response") }.respondsWith(ResponseData::class) { + status(200) + body(ResponseData("Hello", System.currentTimeMillis())) + } + + server.shutdown() + } + + @Test + fun `should handle streaming response with explicit type`() { + val server = MokksyServer() + + data class StreamItem(val id: Int, val data: String) + + server.get { path("/typed-stream") }.respondsWithStream(StreamItem::class) { + // Stream configuration would go here + } + + server.shutdown() + } + + @Test + fun `should handle SSE response with explicit type`() { + val server = MokksyServer() + + data class EventData(val event: String, val payload: String) + + server.get { path("/typed-sse") }.respondsWithSseStream(EventData::class) { + // SSE configuration would go here + } + + server.shutdown() + } + + @Test + fun `should handle wait parameter correctly`() { + // Test non-blocking server creation (default behavior) + val server1 = MokksyServer(wait = false) + assertTrue(server1.port() > 0) + server1.shutdown() + + // Test with explicit wait parameter in full constructor + val config = ServerConfiguration(verbose = false) + val server2 = MokksyServer( + port = 0, + host = "127.0.0.1", + configuration = config, + wait = false + ) {} + assertTrue(server2.port() > 0) + server2.shutdown() + } + + @Test + fun `should maintain thread safety for concurrent operations`() = runTest { + val server = MokksyServer() + + // Perform concurrent operations + val operations = (1..20).map { index -> + async { + when (index % 4) { + 0 -> server.get { path("/thread-safe-get-$index") } + 1 -> server.post { path("/thread-safe-post-$index") } + 2 -> server.findAllUnmatchedRequests() + else -> server.resetMatchCounts() + } + } + } + + // Wait for all operations to complete + operations.awaitAll() + + // Should have registered stubs without conflicts + val unmatchedRequests = server.findAllUnmatchedRequests() + assertTrue(unmatchedRequests.size >= 10) // At least the GET and POST operations + + server.shutdown() + } + + // Helper functions for testing + + private fun createTestServer(): MokksyServer { + return MokksyServer() + } + + private suspend fun delayedAssert(delayMs: Long, assertion: () -> Unit) { + delay(delayMs) + assertion() + } +} \ No newline at end of file diff --git a/mokksy/src/commonTest/kotlin/me/kpavlov/mokksy/RequestHandlerTest.kt b/mokksy/src/commonTest/kotlin/me/kpavlov/mokksy/RequestHandlerTest.kt new file mode 100644 index 00000000..67073e7c --- /dev/null +++ b/mokksy/src/commonTest/kotlin/me/kpavlov/mokksy/RequestHandlerTest.kt @@ -0,0 +1,422 @@ +package me.kpavlov.mokksy + +import io.kotest.assertions.failure +import io.ktor.server.application.Application +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.ApplicationRequest +import io.ktor.server.routing.RoutingContext +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import kotlinx.coroutines.test.runTest +import me.kpavlov.mokksy.utils.logger.HttpFormatter +import kotlin.test.* + +/** + * Comprehensive unit tests for RequestHandler functionality. + * Uses Kotlin Test framework with MockK for mocking dependencies. + * Tests the handleRequest suspend function and related functionality. + */ +class RequestHandlerTest { + + @MockK + private lateinit var context: RoutingContext + + @MockK + private lateinit var application: Application + + @MockK + private lateinit var call: ApplicationCall + + @MockK + private lateinit var request: ApplicationRequest + + @MockK + private lateinit var formatter: HttpFormatter + + @MockK + private lateinit var stubSpecification: RequestSpecification<*> + + private lateinit var stubs: MutableSet> + private lateinit var configuration: ServerConfiguration + + @BeforeTest + fun setUp() { + MockKAnnotations.init(this) + stubs = mutableSetOf() + configuration = ServerConfiguration(verbose = false) + + // Default mock setup + every { context.call } returns call + every { call.request } returns request + every { formatter.formatRequest(any()) } returns "Mock Request Format" + } + + @AfterTest + fun tearDown() { + clearAllMocks() + } + + // ================================= + // Happy Path Tests + // ================================= + + @Test + fun testHandleRequestWithMatchingStub() = runTest { + // Given + val mockStub = createMockStub(true, removeAfterMatch = false) + stubs.add(mockStub) + + // When + handleRequest(context, application, stubs, configuration, formatter) + + // Then + verify { mockStub.incrementMatchCount() } + verify { mockStub.respond(call, false) } + assertEquals(1, stubs.size) // Stub should not be removed + } + + @Test + fun testHandleRequestWithMatchingStubRemoval() = runTest { + // Given + val mockStub = createMockStub(true, removeAfterMatch = true) + stubs.add(mockStub) + configuration = ServerConfiguration(verbose = true) + + // When + handleRequest(context, application, stubs, configuration, formatter) + + // Then + verify { mockStub.incrementMatchCount() } + verify { mockStub.respond(call, true) } + assertEquals(0, stubs.size) // Stub should be removed + } + + @Test + fun testHandleRequestWithMultipleStubsSelectsFirst() = runTest { + // Given + val firstStub = createMockStub(true, removeAfterMatch = false, priority = 1) + val secondStub = createMockStub(true, removeAfterMatch = false, priority = 2) + stubs.addAll(listOf(firstStub, secondStub)) + + // When + handleRequest(context, application, stubs, configuration, formatter) + + // Then + verify { firstStub.incrementMatchCount() } + verify { firstStub.respond(call, false) } + verify(exactly = 0) { secondStub.incrementMatchCount() } + verify(exactly = 0) { secondStub.respond(any(), any()) } + } + + @Test + fun testHandleRequestWithVerboseLogging() = runTest { + // Given + val mockStub = createMockStub(true, removeAfterMatch = false, verboseStub = true) + stubs.add(mockStub) + configuration = ServerConfiguration(verbose = true) + + // When + handleRequest(context, application, stubs, configuration, formatter) + + // Then + verify { mockStub.respond(call, true) } // Should pass verbose=true + } + + // ================================= + // No Match Scenarios + // ================================= + + @Test + fun testHandleRequestWithNoMatchingStubs() = runTest { + // Given + val mockStub = createMockStub(false, removeAfterMatch = false) + stubs.add(mockStub) + + // When/Then + assertFailsWith { + handleRequest(context, application, stubs, configuration, formatter) + } + + verify(exactly = 0) { mockStub.incrementMatchCount() } + verify(exactly = 0) { mockStub.respond(any(), any()) } + } + + @Test + fun testHandleRequestWithEmptyStubSet() = runTest { + // Given - empty stubs set + + // When/Then + assertFailsWith { + handleRequest(context, application, stubs, configuration, formatter) + } + } + + @Test + fun testHandleRequestNoMatchWithVerboseLogging() = runTest { + // Given + val mockStub = createMockStub(false, removeAfterMatch = false) + stubs.add(mockStub) + configuration = ServerConfiguration(verbose = true) + every { mockStub.toLogString() } returns "Mock Stub Log" + + // When/Then + assertFailsWith { + handleRequest(context, application, stubs, configuration, formatter) + } + + verify { formatter.formatRequest(request) } + verify { mockStub.toLogString() } + } + + // ================================= + // Error Handling Tests + // ================================= + + @Test + fun testHandleRequestWithStubEvaluationFailure() = runTest { + // Given + val mockStub = createMockStub(matchResult = null, removeAfterMatch = false) // Simulation of evaluation failure + stubs.add(mockStub) + configuration = ServerConfiguration(verbose = true) + + // When/Then + assertFailsWith { + handleRequest(context, application, stubs, configuration, formatter) + } + } + + @Test + fun testHandleRequestWithStubEvaluationException() = runTest { + // Given + val mockStub = createMockStubWithException() + stubs.add(mockStub) + configuration = ServerConfiguration(verbose = true) + + // When/Then + assertFailsWith { + handleRequest(context, application, stubs, configuration, formatter) + } + } + + // ================================= + // Configuration Tests + // ================================= + + @Test + fun testHandleRequestWithNonVerboseConfiguration() = runTest { + // Given + val mockStub = createMockStub(true, removeAfterMatch = false) + stubs.add(mockStub) + configuration = ServerConfiguration(verbose = false) + + // When + handleRequest(context, application, stubs, configuration, formatter) + + // Then + verify { mockStub.respond(call, false) } // Should pass verbose=false + } + + @Test + fun testHandleRequestRemovalLogging() = runTest { + // Given + val mockStub = createMockStub(true, removeAfterMatch = true) + stubs.add(mockStub) + configuration = ServerConfiguration(verbose = true) + every { mockStub.toLogString() } returns "Removed Stub Log" + + // When + handleRequest(context, application, stubs, configuration, formatter) + + // Then + assertEquals(0, stubs.size) + verify { mockStub.toLogString() } + } + + // ================================= + // Stub Comparison and Ordering Tests + // ================================= + + @Test + fun testStubComparatorOrdering() = runTest { + // Given + val highPriorityStub = createMockStub(true, removeAfterMatch = false, priority = 1) + val lowPriorityStub = createMockStub(true, removeAfterMatch = false, priority = 10) + stubs.addAll(listOf(lowPriorityStub, highPriorityStub)) // Add in reverse order + + // When + handleRequest(context, application, stubs, configuration, formatter) + + // Then - Should select the stub with lower priority value (higher priority) + verify { highPriorityStub.incrementMatchCount() } + verify { highPriorityStub.respond(call, false) } + verify(exactly = 0) { lowPriorityStub.incrementMatchCount() } + } + + // ================================= + // Request Specification Tests + // ================================= + + @Test + fun testRequestSpecificationMatching() = runTest { + // Given + val mockStub = mockk>() + val mockSpec = mockk>() + val mockConfig = StubConfiguration(removeAfterMatch = false, verbose = false) + + every { mockStub.requestSpecification } returns mockSpec + every { mockStub.configuration } returns mockConfig + every { mockStub.incrementMatchCount() } just Runs + every { mockStub.respond(any(), any()) } just Runs + every { mockSpec.matches(request) } returns Result.success(true) + + stubs.add(mockStub) + + // When + handleRequest(context, application, stubs, configuration, formatter) + + // Then + verify { mockSpec.matches(request) } + verify { mockStub.incrementMatchCount() } + verify { mockStub.respond(call, false) } + } + + // ================================= + // Concurrency and Performance Tests + // ================================= + + @Test + fun testHandleMultipleRequestsConcurrently() = runTest { + // Given + val stub1 = createMockStub(true, removeAfterMatch = false) + val stub2 = createMockStub(true, removeAfterMatch = false) + stubs.addAll(listOf(stub1, stub2)) + + // When - Simulate multiple concurrent requests + repeat(5) { + handleRequest(context, application, stubs, configuration, formatter) + } + + // Then - Should handle all requests (first matching stub wins each time) + verify(exactly = 5) { stub1.incrementMatchCount() } + verify(exactly = 5) { stub1.respond(call, false) } + verify(exactly = 0) { stub2.incrementMatchCount() } + } + + @Test + fun testStubRemovalConcurrency() = runTest { + // Given + val removableStub = createMockStub(true, removeAfterMatch = true) + val persistentStub = createMockStub(true, removeAfterMatch = false, priority = 2) + stubs.addAll(listOf(removableStub, persistentStub)) + + // When - First request should remove the first stub + handleRequest(context, application, stubs, configuration, formatter) + // Second request should use the remaining stub + handleRequest(context, application, stubs, configuration, formatter) + + // Then + verify(exactly = 1) { removableStub.incrementMatchCount() } + verify(exactly = 1) { persistentStub.incrementMatchCount() } + assertEquals(1, stubs.size) // Only persistent stub remains + } + + // ================================= + // Edge Cases and Boundary Tests + // ================================= + + @Test + fun testHandleRequestWithLargeStubSet() = runTest { + // Given + val manyStubs = (1..100).map { i -> + createMockStub(i == 50, removeAfterMatch = false, priority = i) // Only 50th stub matches + } + stubs.addAll(manyStubs) + + // When + handleRequest(context, application, stubs, configuration, formatter) + + // Then + val matchingStub = manyStubs[49] // 50th stub (0-indexed) + verify { matchingStub.incrementMatchCount() } + verify { matchingStub.respond(call, false) } + } + + @Test + fun testHandleRequestFormatterIntegration() = runTest { + // Given + val mockStub = createMockStub(false, removeAfterMatch = false) + stubs.add(mockStub) + every { formatter.formatRequest(request) } returns "Detailed Request Format" + + // When/Then + assertFailsWith { + handleRequest(context, application, stubs, configuration, formatter) + } + + verify { formatter.formatRequest(request) } + } + + // ================================= + // Helper Methods + // ================================= + + private fun createMockStub( + matchResult: Boolean?, + removeAfterMatch: Boolean, + priority: Int = 1, + verboseStub: Boolean = false + ): Stub<*, *> { + val mockStub = mockk>() + val mockSpec = mockk>() + val stubConfig = StubConfiguration(removeAfterMatch = removeAfterMatch, verbose = verboseStub) + + every { mockStub.requestSpecification } returns mockSpec + every { mockStub.configuration } returns stubConfig + every { mockStub.incrementMatchCount() } just Runs + every { mockStub.respond(any(), any()) } just Runs + every { mockStub.toLogString() } returns "Mock Stub [$priority]" + + when (matchResult) { + true -> every { mockSpec.matches(request) } returns Result.success(true) + false -> every { mockSpec.matches(request) } returns Result.success(false) + null -> every { mockSpec.matches(request) } returns Result.failure(RuntimeException("Evaluation failed")) + } + + // Mock comparable behavior for stub comparison + every { mockStub.compareTo(any()) } answers { + val other = firstArg>() + priority.compareTo((other.toLogString().substringAfter("[").substringBefore("]").toIntOrNull() ?: Int.MAX_VALUE)) + } + + return mockStub + } + + private fun createMockStubWithException(): Stub<*, *> { + val mockStub = mockk>() + val mockSpec = mockk>() + val stubConfig = StubConfiguration(removeAfterMatch = false, verbose = false) + + every { mockStub.requestSpecification } returns mockSpec + every { mockStub.configuration } returns stubConfig + every { mockStub.toLogString() } returns "Exception Stub" + every { mockSpec.matches(request) } throws RuntimeException("Matching failed") + + return mockStub + } + + /** + * Data class representing server configuration for testing + */ + data class ServerConfiguration( + val verbose: Boolean = false + ) + + /** + * Data class representing stub configuration for testing + */ + data class StubConfiguration( + val removeAfterMatch: Boolean = false, + val verbose: Boolean = false + ) +} \ No newline at end of file diff --git a/mokksy/src/commonTest/kotlin/me/kpavlov/mokksy/utils/StringsTest.kt b/mokksy/src/commonTest/kotlin/me/kpavlov/mokksy/utils/StringsTest.kt new file mode 100644 index 00000000..43ceda7a --- /dev/null +++ b/mokksy/src/commonTest/kotlin/me/kpavlov/mokksy/utils/StringsTest.kt @@ -0,0 +1,268 @@ +package me.kpavlov.mokksy.utils + +import assertk.assertThat +import assertk.assertions.* +import kotlin.test.Test + +/** + * Comprehensive unit tests for the String.ellipsizeMiddle extension function. + * Testing framework: kotlin.test with assertk assertions (following project conventions). + */ +class StringsTest { + + // Tests for ellipsizeMiddle function - null handling + @Test + fun `ellipsizeMiddle should return null when input is null`() { + val result: String? = null + assertThat(result.ellipsizeMiddle(10)).isNull() + } + + @Test + fun `ellipsizeMiddle should return null when input is null regardless of maxLength`() { + val result: String? = null + assertThat(result.ellipsizeMiddle(5)).isNull() + assertThat(result.ellipsizeMiddle(100)).isNull() + assertThat(result.ellipsizeMiddle(0)).isNull() + assertThat(result.ellipsizeMiddle(-1)).isNull() + } + + // Tests for ellipsizeMiddle function - length conditions + @Test + fun `ellipsizeMiddle should return original string when length is less than or equal to maxLength`() { + assertThat("hello".ellipsizeMiddle(5)).isEqualTo("hello") + assertThat("hello".ellipsizeMiddle(6)).isEqualTo("hello") + assertThat("hello".ellipsizeMiddle(10)).isEqualTo("hello") + assertThat("test".ellipsizeMiddle(4)).isEqualTo("test") + assertThat("a".ellipsizeMiddle(1)).isEqualTo("a") + assertThat("".ellipsizeMiddle(0)).isEqualTo("") + assertThat("".ellipsizeMiddle(5)).isEqualTo("") + } + + // Tests for ellipsizeMiddle function - maxLength less than 5 + @Test + fun `ellipsizeMiddle should return original string when maxLength is less than 5`() { + assertThat("hello world".ellipsizeMiddle(4)).isEqualTo("hello world") + assertThat("hello world".ellipsizeMiddle(3)).isEqualTo("hello world") + assertThat("hello world".ellipsizeMiddle(2)).isEqualTo("hello world") + assertThat("hello world".ellipsizeMiddle(1)).isEqualTo("hello world") + assertThat("hello world".ellipsizeMiddle(0)).isEqualTo("hello world") + assertThat("very long string that needs truncation".ellipsizeMiddle(-1)).isEqualTo("very long string that needs truncation") + } + + // Tests for ellipsizeMiddle function - basic truncation + @Test + fun `ellipsizeMiddle should truncate string with ellipsis when conditions are met`() { + // "hello world" (11 chars) -> maxLength 8 -> "he...ld" (7 chars) + assertThat("hello world".ellipsizeMiddle(8)).isEqualTo("he...ld") + + // "hello world" (11 chars) -> maxLength 7 -> "he...ld" (7 chars) + assertThat("hello world".ellipsizeMiddle(7)).isEqualTo("he...ld") + + // "hello world" (11 chars) -> maxLength 6 -> "h...ld" (6 chars) + assertThat("hello world".ellipsizeMiddle(6)).isEqualTo("h...ld") + + // "hello world" (11 chars) -> maxLength 5 -> "h...d" (5 chars) + assertThat("hello world".ellipsizeMiddle(5)).isEqualTo("h...d") + } + + // Tests for ellipsizeMiddle function - even maxLength + @Test + fun `ellipsizeMiddle should handle even maxLength correctly`() { + // maxLength = 8: (8-3)/2 = 2.5 -> 2, start gets extra char for odd remainder + // "hello world" -> "he" + "..." + "ld" = "he...ld" + assertThat("hello world".ellipsizeMiddle(8)).isEqualTo("he...ld") + + // maxLength = 10: (10-3)/2 = 3.5 -> 3, start gets extra char + // "hello world" -> "hel" + "..." + "rld" = "hel...rld" + assertThat("hello world".ellipsizeMiddle(10)).isEqualTo("hel...rld") + + // maxLength = 6: (6-3)/2 = 1.5 -> 1, start gets extra char + // "hello world" -> "h" + "..." + "d" = "h...d" + assertThat("hello world".ellipsizeMiddle(6)).isEqualTo("h...d") + } + + // Tests for ellipsizeMiddle function - odd maxLength + @Test + fun `ellipsizeMiddle should handle odd maxLength correctly`() { + // maxLength = 7: (7-3)/2 = 2, no remainder + // "hello world" -> "he" + "..." + "ld" = "he...ld" + assertThat("hello world".ellipsizeMiddle(7)).isEqualTo("he...ld") + + // maxLength = 9: (9-3)/2 = 3, no remainder + // "hello world" -> "hel" + "..." + "rld" = "hel...rld" + assertThat("hello world".ellipsizeMiddle(9)).isEqualTo("hel...rld") + + // maxLength = 5: (5-3)/2 = 1, no remainder + // "hello world" -> "h" + "..." + "d" = "h...d" + assertThat("hello world".ellipsizeMiddle(5)).isEqualTo("h...d") + } + + // Tests for ellipsizeMiddle function - edge cases + @Test + fun `ellipsizeMiddle should handle single character strings`() { + assertThat("a".ellipsizeMiddle(5)).isEqualTo("a") + assertThat("a".ellipsizeMiddle(1)).isEqualTo("a") + assertThat("a".ellipsizeMiddle(0)).isEqualTo("a") + } + + @Test + fun `ellipsizeMiddle should handle empty strings`() { + assertThat("".ellipsizeMiddle(5)).isEqualTo("") + assertThat("".ellipsizeMiddle(0)).isEqualTo("") + assertThat("".ellipsizeMiddle(10)).isEqualTo("") + } + + @Test + fun `ellipsizeMiddle should handle strings exactly at boundary lengths`() { + // String of length exactly 5 with maxLength 5 + assertThat("12345".ellipsizeMiddle(5)).isEqualTo("12345") + + // String of length exactly 6 with maxLength 6 + assertThat("123456".ellipsizeMiddle(6)).isEqualTo("123456") + + // String of length exactly 4 with maxLength 5 (no truncation needed) + assertThat("1234".ellipsizeMiddle(5)).isEqualTo("1234") + } + + // Tests for ellipsizeMiddle function - whitespace and special characters + @Test + fun `ellipsizeMiddle should handle strings with whitespace`() { + assertThat("hello world".ellipsizeMiddle(8)).isEqualTo("he...rld") + assertThat(" hello world ".ellipsizeMiddle(8)).isEqualTo(" ...d ") + assertThat("\thello\tworld\n".ellipsizeMiddle(8)).isEqualTo("\th...ld\n") + } + + @Test + fun `ellipsizeMiddle should handle strings with special characters`() { + assertThat("hello@#$world".ellipsizeMiddle(8)).isEqualTo("he...rld") + assertThat("!@#$%^&*()".ellipsizeMiddle(7)).isEqualTo("!@...*()") + assertThat("hello/world\\test".ellipsizeMiddle(10)).isEqualTo("hel...test") + } + + @Test + fun `ellipsizeMiddle should handle strings with numbers`() { + assertThat("123456789".ellipsizeMiddle(6)).isEqualTo("1...89") + assertThat("abc123def456ghi".ellipsizeMiddle(10)).isEqualTo("abc...6ghi") + } + + // Tests for ellipsizeMiddle function - unicode characters + @Test + fun `ellipsizeMiddle should handle unicode characters`() { + assertThat("hello 世界 world".ellipsizeMiddle(10)).isEqualTo("hel...orld") + assertThat("🌍🌎🌏🌍🌎🌏".ellipsizeMiddle(8)).isEqualTo("🌍🌎...🌎🌏") + assertThat("café naïve résumé".ellipsizeMiddle(10)).isEqualTo("caf...sumé") + } + + // Tests for ellipsizeMiddle function - very long strings + @Test + fun `ellipsizeMiddle should handle very long strings`() { + val longString = "a".repeat(1000) + val result = longString.ellipsizeMiddle(20) + assertThat(result).hasLength(20) + assertThat(result).startsWith("aaaaaaaa") + assertThat(result).contains("...") + assertThat(result).endsWith("aaaaaaaa") + + val veryLongString = "x".repeat(10000) + val shortResult = veryLongString.ellipsizeMiddle(5) + assertThat(shortResult).isEqualTo("x...x") + } + + // Tests for ellipsizeMiddle function - different content patterns + @Test + fun `ellipsizeMiddle should handle mixed alphanumeric content`() { + assertThat("ABC123def456GHI".ellipsizeMiddle(8)).isEqualTo("AB...GHI") + assertThat("test_file_name_123.txt".ellipsizeMiddle(15)).isEqualTo("test_f...23.txt") + assertThat("user@example.com".ellipsizeMiddle(12)).isEqualTo("user...e.com") + } + + @Test + fun `ellipsizeMiddle should handle URL-like strings`() { + assertThat("https://example.com/very/long/path".ellipsizeMiddle(20)).isEqualTo("https://...ng/path") + assertThat("file:///path/to/document.pdf".ellipsizeMiddle(15)).isEqualTo("file:///...ent.pdf") + } + + @Test + fun `ellipsizeMiddle should handle code-like strings`() { + assertThat("com.example.package.ClassName".ellipsizeMiddle(18)).isEqualTo("com.exa...assName") + assertThat("function_with_very_long_name()".ellipsizeMiddle(20)).isEqualTo("functio...g_name()") + } + + // Tests for ellipsizeMiddle function - performance and stress testing + @Test + fun `ellipsizeMiddle should handle repeated operations efficiently`() { + val testString = "This is a test string for performance testing" + repeat(1000) { + val result = testString.ellipsizeMiddle(20) + assertThat(result).hasLength(20) + assertThat(result).contains("...") + } + } + + @Test + fun `ellipsizeMiddle should maintain consistent results`() { + val testString = "consistent test string for validation" + val result1 = testString.ellipsizeMiddle(15) + val result2 = testString.ellipsizeMiddle(15) + val result3 = testString.ellipsizeMiddle(15) + + assertThat(result1).isEqualTo(result2) + assertThat(result2).isEqualTo(result3) + assertThat(result1).isEqualTo("consis...ation") + } + + // Tests for ellipsizeMiddle function - boundary value analysis + @Test + fun `ellipsizeMiddle should handle maxLength of exactly 5`() { + assertThat("123456789".ellipsizeMiddle(5)).isEqualTo("1...9") + assertThat("abcdefghijklmnop".ellipsizeMiddle(5)).isEqualTo("a...p") + assertThat("short".ellipsizeMiddle(5)).isEqualTo("short") + } + + @Test + fun `ellipsizeMiddle should handle maxLength just above minimum`() { + assertThat("123456789".ellipsizeMiddle(6)).isEqualTo("1...89") + assertThat("abcdefghijklmnop".ellipsizeMiddle(7)).isEqualTo("ab...op") + assertThat("testing string".ellipsizeMiddle(8)).isEqualTo("te...ing") + } + + // Tests for ellipsizeMiddle function - mathematical correctness + @Test + fun `ellipsizeMiddle should produce correct length results`() { + val testCases = listOf( + "hello world" to 8, + "testing this function" to 12, + "very long string indeed" to 15, + "short" to 10, + "a" to 5 + ) + + testCases.forEach { (input, maxLen) -> + val result = input.ellipsizeMiddle(maxLen) + if (input.length > maxLen && maxLen >= 5) { + assertThat(result).hasLength(maxLen) + assertThat(result).contains("...") + } else { + assertThat(result).isEqualTo(input) + } + } + } + + @Test + fun `ellipsizeMiddle should maintain start and end portions correctly`() { + val input = "0123456789ABCDEF" + val result = input.ellipsizeMiddle(10) // Should be "012...DEF" or "0123...EF" + + assertThat(result).hasLength(10) + assertThat(result).contains("...") + assertThat(result).startsWith("0") + assertThat(result).endsWith("F") + + // Verify the mathematical distribution + val parts = result.split("...") + assertThat(parts).hasSize(2) + val startPart = parts[0] + val endPart = parts[1] + assertThat(startPart.length + endPart.length + 3).isEqualTo(10) + } +} \ No newline at end of file diff --git a/mokksy/src/commonTest/kotlin/me/kpavlov/mokksy/utils/logger/HighlightingTest.kt b/mokksy/src/commonTest/kotlin/me/kpavlov/mokksy/utils/logger/HighlightingTest.kt new file mode 100644 index 00000000..28bd45b4 --- /dev/null +++ b/mokksy/src/commonTest/kotlin/me/kpavlov/mokksy/utils/logger/HighlightingTest.kt @@ -0,0 +1,367 @@ +package me.kpavlov.mokksy.utils.logger + +import io.ktor.http.ContentType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import kotlin.test.assertContains +import kotlin.test.assertNotNull + +/** + * Comprehensive unit tests for the Highlighting functionality. + * Tests cover happy paths, edge cases, and failure conditions for all public methods. + */ +class HighlightingTest { + + // JSON Highlighting Tests + @Test + fun testHighlightBodyWithSimpleJson() { + val json = """{"name": "Alice", "age": 30}""" + val result = Highlighting.highlightBody(json, ContentType.Application.Json) + + assertNotNull(result) + assertNotEquals(json, result) + // Should contain ANSI color codes + assertTrue(result.contains("\u001B["), "Result should contain ANSI color codes") + // Should preserve the original structure + assertContains(result, "name") + assertContains(result, "Alice") + assertContains(result, "age") + assertContains(result, "30") + } + + @Test + fun testHighlightBodyWithComplexJson() { + val json = """ + { + "user": { + "name": "John Doe", + "age": 42, + "active": true, + "balance": 123.45, + "address": null + }, + "metadata": { + "created": "2023-01-01T00:00:00Z", + "tags": ["user", "premium"], + "count": 0 + } + } + """.trimIndent() + + val result = Highlighting.highlightBody(json, ContentType.Application.Json) + + assertNotNull(result) + assertNotEquals(json, result) + assertTrue(result.contains("\u001B[")) + + // Check that all JSON elements are preserved + assertContains(result, "John Doe") + assertContains(result, "42") + assertContains(result, "true") + assertContains(result, "123.45") + assertContains(result, "null") + assertContains(result, "2023-01-01T00:00:00Z") + assertContains(result, "user") + assertContains(result, "premium") + assertContains(result, "0") + } + + @Test + fun testHighlightBodyWithJsonBooleanAndNullValues() { + val json = """{"isActive": true, "isDeleted": false, "middleName": null}""" + val result = Highlighting.highlightBody(json, ContentType.Application.Json) + + assertNotNull(result) + assertTrue(result.contains("\u001B[")) + assertContains(result, "true") + assertContains(result, "false") + assertContains(result, "null") + } + + @Test + fun testHighlightBodyWithJsonNumericValues() { + val json = """{"integer": 42, "float": 3.14159, "negative": -100, "zero": 0}""" + val result = Highlighting.highlightBody(json, ContentType.Application.Json) + + assertNotNull(result) + assertTrue(result.contains("\u001B[")) + assertContains(result, "42") + assertContains(result, "3.14159") + assertContains(result, "-100") + assertContains(result, "0") + } + + @Test + fun testHighlightBodyWithJsonSpecialCharacters() { + val json = """{"special": "hello\nworld", "unicode": "café 🌟", "escaped": "\"quoted\""}""" + val result = Highlighting.highlightBody(json, ContentType.Application.Json) + + assertNotNull(result) + assertTrue(result.contains("\u001B[")) + assertContains(result, "hello\\nworld") + assertContains(result, "café 🌟") + assertContains(result, "\\\"quoted\\\"") + } + + @Test + fun testHighlightBodyWithMalformedJson() { + val malformedJson = """{"name": "Alice", "age": }""" + val result = Highlighting.highlightBody(malformedJson, ContentType.Application.Json) + + // Should handle malformed JSON gracefully + assertNotNull(result) + assertContains(result, "Alice") + } + + @Test + fun testHighlightBodyWithEmptyJson() { + val emptyJson = "{}" + val result = Highlighting.highlightBody(emptyJson, ContentType.Application.Json) + + assertNotNull(result) + assertEquals(emptyJson, result) // Should return unchanged since no key-value pairs to highlight + } + + @Test + fun testHighlightBodyWithJsonWhitespacePreservation() { + val json = """{"name" : "Alice", "age":42}""" + val result = Highlighting.highlightBody(json, ContentType.Application.Json) + + assertNotNull(result) + assertTrue(result.contains("\u001B[")) + // Should preserve spacing around colons + assertTrue(result.contains(" : ") || result.contains(": ")) + } + + // Form URL-Encoded Highlighting Tests + @Test + fun testHighlightBodyWithSimpleFormData() { + val formData = "name=Alice&age=30&active=true" + val result = Highlighting.highlightBody(formData, ContentType.Application.FormUrlEncoded) + + assertNotNull(result) + assertNotEquals(formData, result) + assertTrue(result.contains("\u001B[")) + assertContains(result, "name") + assertContains(result, "Alice") + assertContains(result, "age") + assertContains(result, "30") + assertContains(result, "active") + assertContains(result, "true") + assertEquals(2, result.count { it == '&' }) // Should preserve ampersands + } + + @Test + fun testHighlightBodyWithFormDataSpecialCharacters() { + val formData = "email=user%40example.com&message=Hello%20World" + val result = Highlighting.highlightBody(formData, ContentType.Application.FormUrlEncoded) + + assertNotNull(result) + assertTrue(result.contains("\u001B[")) + assertContains(result, "email") + assertContains(result, "user%40example.com") + assertContains(result, "message") + assertContains(result, "Hello%20World") + } + + @Test + fun testHighlightBodyWithFormDataInvalidPairs() { + val formData = "validkey=value&invalidpair&anotherkey=anothervalue" + val result = Highlighting.highlightBody(formData, ContentType.Application.FormUrlEncoded) + + assertNotNull(result) + assertTrue(result.contains("\u001B[")) + assertContains(result, "validkey") + assertContains(result, "value") + assertContains(result, "invalidpair") // Should be left unchanged + assertContains(result, "anotherkey") + assertContains(result, "anothervalue") + } + + @Test + fun testHighlightBodyWithFormDataEmptyValues() { + val formData = "key1=&key2=value&key3=" + val result = Highlighting.highlightBody(formData, ContentType.Application.FormUrlEncoded) + + assertNotNull(result) + assertTrue(result.contains("\u001B[")) + assertContains(result, "key1") + assertContains(result, "key2") + assertContains(result, "key3") + assertContains(result, "value") + } + + @Test + fun testHighlightBodyWithFormDataMultipleEquals() { + val formData = "equation=1+1=2&url=http://example.com" + val result = Highlighting.highlightBody(formData, ContentType.Application.FormUrlEncoded) + + assertNotNull(result) + // Should handle pairs with multiple equals signs correctly + assertContains(result, "equation") + assertContains(result, "url") + } + + @Test + fun testHighlightBodyWithEmptyFormData() { + val formData = "" + val result = Highlighting.highlightBody(formData, ContentType.Application.FormUrlEncoded) + + assertNotNull(result) + assertEquals(formData, result) // Empty string should remain unchanged + } + + // Other Content Types Tests + @Test + fun testHighlightBodyWithTextPlain() { + val text = "This is plain text content" + val result = Highlighting.highlightBody(text, ContentType.Text.Plain) + + assertNotNull(result) + assertTrue(result.contains("\u001B[")) + assertContains(result, text) + // Should be colored with light gray + } + + @Test + fun testHighlightBodyWithApplicationXml() { + val xml = "value" + val result = Highlighting.highlightBody(xml, ContentType.Application.Xml) + + assertNotNull(result) + assertTrue(result.contains("\u001B[")) + assertContains(result, xml) + // Should be colored with light gray + } + + @Test + fun testHighlightBodyWithCustomContentType() { + val content = "Custom content type data" + val customContentType = ContentType("application", "custom") + val result = Highlighting.highlightBody(content, customContentType) + + assertNotNull(result) + assertTrue(result.contains("\u001B[")) + assertContains(result, content) + // Should be colored with light gray + } + + // Edge Cases and Error Conditions + @Test + fun testHighlightBodyWithEmptyString() { + val empty = "" + val result = Highlighting.highlightBody(empty, ContentType.Application.Json) + + assertNotNull(result) + assertEquals(empty, result) + } + + @Test + fun testHighlightBodyWithVeryLongContent() { + val longContent = "key=value&".repeat(1000).dropLast(1) // Remove trailing & + val result = Highlighting.highlightBody(longContent, ContentType.Application.FormUrlEncoded) + + assertNotNull(result) + assertTrue(result.contains("\u001B[")) + assertTrue(result.length >= longContent.length) + } + + @Test + fun testHighlightBodyWithUnicodeContent() { + val unicodeJson = """{"message": "Hello 世界 🌍", "café": "résumé"}""" + val result = Highlighting.highlightBody(unicodeJson, ContentType.Application.Json) + + assertNotNull(result) + assertTrue(result.contains("\u001B[")) + assertContains(result, "Hello 世界 🌍") + assertContains(result, "résumé") + } + + @Test + fun testHighlightBodyWithMultilineContent() { + val multilineJson = """ + { + "line1": "First line", + "line2": "Second line", + "line3": "Third line" + } + """.trimIndent() + + val result = Highlighting.highlightBody(multilineJson, ContentType.Application.Json) + + assertNotNull(result) + assertTrue(result.contains("\u001B[")) + assertContains(result, "First line") + assertContains(result, "Second line") + assertContains(result, "Third line") + } + + @Test + fun testHighlightBodyConsistency() { + val json = """{"test": "consistency"}""" + val result1 = Highlighting.highlightBody(json, ContentType.Application.Json) + val result2 = Highlighting.highlightBody(json, ContentType.Application.Json) + + assertEquals(result1, result2, "Same input should produce consistent output") + } + + @Test + fun testHighlightBodyPerformance() { + val largeJson = """{"data": "${"x".repeat(10000)}"}""" + + val startTime = kotlin.system.getTimeMillis() + val result = Highlighting.highlightBody(largeJson, ContentType.Application.Json) + val endTime = kotlin.system.getTimeMillis() + + assertNotNull(result) + assertTrue(result.contains("\u001B[")) + assertTrue(endTime - startTime < 5000, "Highlighting should complete in reasonable time") + } + + @Test + fun testHighlightBodyWithNestedQuotesInJson() { + val json = """{"message": "He said \"Hello\" to me", "code": "if (x == \"test\") { return; }"}""" + val result = Highlighting.highlightBody(json, ContentType.Application.Json) + + assertNotNull(result) + assertTrue(result.contains("\u001B[")) + assertContains(result, "He said \\\"Hello\\\" to me") + assertContains(result, "if (x == \\\"test\\\") { return; }") + } + + @Test + fun testHighlightBodyThreadSafety() { + val json = """{"concurrent": "test"}""" + val results = mutableListOf() + + // Simple concurrent access test + repeat(10) { + results.add(Highlighting.highlightBody(json, ContentType.Application.Json)) + } + + assertTrue(results.all { it == results.first() }, "Thread safety: all results should be identical") + assertTrue(results.all { it.contains("\u001B[") }, "All results should contain ANSI codes") + } + + @Test + fun testHighlightBodyWithDifferentJsonContentTypes() { + val json = """{"type": "test"}""" + + // Test with different JSON content type variations + val contentTypes = listOf( + ContentType.Application.Json, + ContentType("application", "json", listOf("charset" to "utf-8")), + ContentType("application", "vnd.api+json") + ) + + contentTypes.forEach { contentType -> + val result = Highlighting.highlightBody(json, contentType) + assertNotNull(result, "Failed for content type: $contentType") + if (contentType.match(ContentType.Application.Json)) { + assertTrue(result.contains("\u001B["), "Should highlight JSON for content type: $contentType") + } + } + } +} \ No newline at end of file diff --git a/mokksy/src/jvmMain/kotlin/me/kpavlov/mokksy/utils/logger/HttpFormatter.jvm.kt b/mokksy/src/jvmMain/kotlin/me/kpavlov/mokksy/utils/logger/HttpFormatter.jvm.kt new file mode 100644 index 00000000..6045507c --- /dev/null +++ b/mokksy/src/jvmMain/kotlin/me/kpavlov/mokksy/utils/logger/HttpFormatter.jvm.kt @@ -0,0 +1,12 @@ +package me.kpavlov.mokksy.utils.logger + +import org.fusesource.jansi.Ansi + +/** + * Determines whether ANSI color output is supported in the current environment. + * + * @return `true` if ANSI color support is detected; otherwise, `false`. + */ +internal actual fun isColorSupported(): Boolean = + runCatching { Ansi::class.java.getMethod("isDetected").invoke(null) as Boolean } + .getOrDefault(false) diff --git a/mokksy/src/jvmTest/kotlin/me/kpavlov/mokksy/utils/logger/HttpFormatterJvmTest.kt b/mokksy/src/jvmTest/kotlin/me/kpavlov/mokksy/utils/logger/HttpFormatterJvmTest.kt new file mode 100644 index 00000000..79b3a148 --- /dev/null +++ b/mokksy/src/jvmTest/kotlin/me/kpavlov/mokksy/utils/logger/HttpFormatterJvmTest.kt @@ -0,0 +1,560 @@ +package me.kpavlov.mokksy.utils.logger + +import assertk.assertThat +import assertk.assertions.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.routing.* +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import org.junit.jupiter.params.provider.ValueSource + +/** + * Comprehensive unit tests for HttpFormatter JVM implementation. + * Testing framework: JUnit 5 with MockK for mocking and AssertK for assertions. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class HttpFormatterJvmTest { + + private lateinit var httpFormatterWithColor: HttpFormatter + private lateinit var httpFormatterWithoutColor: HttpFormatter + + @BeforeEach + fun setUp() { + httpFormatterWithColor = HttpFormatter(useColor = true) + httpFormatterWithoutColor = HttpFormatter(useColor = false) + } + + @Test + fun `constructor should initialize with default light on dark theme`() { + // When + val formatter = HttpFormatter() + + // Then + assertThat(formatter.colors.path).isEqualTo(AnsiColor.STRONGER) + assertThat(formatter.colors.headerName).isEqualTo(AnsiColor.YELLOW) + assertThat(formatter.colors.headerValue).isEqualTo(AnsiColor.PALE) + assertThat(formatter.colors.body).isEqualTo(AnsiColor.LIGHT_GRAY) + } + + @Test + fun `constructor should initialize with dark on light theme`() { + // When + val formatter = HttpFormatter(theme = ColorTheme.DARK_ON_LIGHT) + + // Then + assertThat(formatter.colors.path).isEqualTo(AnsiColor.STRONGER) + assertThat(formatter.colors.headerName).isEqualTo(AnsiColor.BLACK) + assertThat(formatter.colors.headerValue).isEqualTo(AnsiColor.PALE) + assertThat(formatter.colors.body).isEqualTo(AnsiColor.LIGHT_GRAY) + } + + @Test + fun `requestLine should format GET request correctly with color`() { + // Given + val method = HttpMethod.Get + val path = "/api/users" + + // When + val result = httpFormatterWithColor.requestLine(method, path) + + // Then + assertThat(result).isNotNull() + assertThat(result).contains("GET") + assertThat(result).contains("/api/users") + // Should contain ANSI color codes when color is enabled + assertThat(result).contains(AnsiColor.BLUE.code) // GET method color + assertThat(result).contains(AnsiColor.STRONGER.code) // path color + } + + @Test + fun `requestLine should format GET request correctly without color`() { + // Given + val method = HttpMethod.Get + val path = "/api/users" + + // When + val result = httpFormatterWithoutColor.requestLine(method, path) + + // Then + assertThat(result).isNotNull() + assertThat(result).isEqualTo("GET /api/users") + // Should not contain ANSI color codes when color is disabled + assertThat(result).doesNotContain(AnsiColor.BLUE.code) + assertThat(result).doesNotContain(AnsiColor.STRONGER.code) + } + + @ParameterizedTest + @EnumSource(value = HttpMethod::class, names = ["Get", "Post", "Put", "Delete", "Patch", "Head", "Options"]) + fun `requestLine should handle all HTTP methods correctly`(method: HttpMethod) { + // Given + val path = "/api/test" + + // When + val result = httpFormatterWithColor.requestLine(method, path) + + // Then + assertThat(result).isNotNull() + assertThat(result).contains(method.value) + assertThat(result).contains(path) + } + + @Test + fun `requestLine should format POST request with green color`() { + // Given + val method = HttpMethod.Post + val path = "/api/users" + + // When + val result = httpFormatterWithColor.requestLine(method, path) + + // Then + assertThat(result).contains("POST") + assertThat(result).contains("/api/users") + assertThat(result).contains(AnsiColor.GREEN.code) // POST method color + } + + @Test + fun `requestLine should format DELETE request with red color`() { + // Given + val method = HttpMethod.Delete + val path = "/api/users/123" + + // When + val result = httpFormatterWithColor.requestLine(method, path) + + // Then + assertThat(result).contains("DELETE") + assertThat(result).contains("/api/users/123") + assertThat(result).contains(AnsiColor.RED.code) // DELETE method color + } + + @Test + fun `requestLine should format other methods with bold color`() { + // Given + val method = HttpMethod.Put + val path = "/api/users/123" + + // When + val result = httpFormatterWithColor.requestLine(method, path) + + // Then + assertThat(result).contains("PUT") + assertThat(result).contains("/api/users/123") + assertThat(result).contains(AnsiColor.STRONGER.code) // Bold for other methods + } + + @Test + fun `requestLine should handle paths with query parameters`() { + // Given + val method = HttpMethod.Get + val path = "/api/users?page=1&limit=10&sort=name" + + // When + val result = httpFormatterWithColor.requestLine(method, path) + + // Then + assertThat(result).contains("GET") + assertThat(result).contains("/api/users?page=1&limit=10&sort=name") + } + + @Test + fun `requestLine should handle paths with special characters`() { + // Given + val method = HttpMethod.Get + val path = "/api/users/search?q=john%20doe&filter=active" + + // When + val result = httpFormatterWithColor.requestLine(method, path) + + // Then + assertThat(result).contains("GET") + assertThat(result).contains("/api/users/search?q=john%20doe&filter=active") + } + + @Test + fun `header should format single header value correctly with color`() { + // Given + val headerName = "Content-Type" + val headerValues = listOf("application/json") + + // When + val result = httpFormatterWithColor.header(headerName, headerValues) + + // Then + assertThat(result).isNotNull() + assertThat(result).contains("Content-Type") + assertThat(result).contains("[application/json]") + assertThat(result).contains(AnsiColor.YELLOW.code) // header name color + assertThat(result).contains(AnsiColor.PALE.code) // header value color + } + + @Test + fun `header should format single header value correctly without color`() { + // Given + val headerName = "Content-Type" + val headerValues = listOf("application/json") + + // When + val result = httpFormatterWithoutColor.header(headerName, headerValues) + + // Then + assertThat(result).isEqualTo("Content-Type: [application/json]") + assertThat(result).doesNotContain(AnsiColor.YELLOW.code) + assertThat(result).doesNotContain(AnsiColor.PALE.code) + } + + @Test + fun `header should format multiple header values correctly`() { + // Given + val headerName = "Set-Cookie" + val headerValues = listOf("session=abc123", "theme=dark", "lang=en") + + // When + val result = httpFormatterWithColor.header(headerName, headerValues) + + // Then + assertThat(result).contains("Set-Cookie") + assertThat(result).contains("[session=abc123,theme=dark,lang=en]") + } + + @Test + fun `header should handle empty header values`() { + // Given + val headerName = "X-Custom-Header" + val headerValues = emptyList() + + // When + val result = httpFormatterWithColor.header(headerName, headerValues) + + // Then + assertThat(result).contains("X-Custom-Header") + assertThat(result).contains("[]") + } + + @Test + fun `header should handle header with special characters`() { + // Given + val headerName = "X-Request-ID" + val headerValues = listOf("abc-123-def-456") + + // When + val result = httpFormatterWithColor.header(headerName, headerValues) + + // Then + assertThat(result).contains("X-Request-ID") + assertThat(result).contains("[abc-123-def-456]") + } + + @Test + fun `formatBody should return empty string for null body`() { + // When + val result = httpFormatterWithColor.formatBody(null) + + // Then + assertThat(result).isEmpty() + } + + @Test + fun `formatBody should return empty string for blank body`() { + // When + val result = httpFormatterWithColor.formatBody(" ") + + // Then + assertThat(result).isEmpty() + } + + @Test + fun `formatBody should return empty string for empty body`() { + // When + val result = httpFormatterWithColor.formatBody("") + + // Then + assertThat(result).isEmpty() + } + + @Test + fun `formatBody should return body as-is when color is disabled`() { + // Given + val body = """{"name":"John","age":30}""" + + // When + val result = httpFormatterWithoutColor.formatBody(body, ContentType.Application.Json) + + // Then + assertThat(result).isEqualTo(body) + } + + @Test + fun `formatBody should apply highlighting when color is enabled`() { + // Given + val body = """{"name":"John","age":30}""" + + // When + val result = httpFormatterWithColor.formatBody(body, ContentType.Application.Json) + + // Then + assertThat(result).isNotNull() + assertThat(result).contains("John") + assertThat(result).contains("30") + // Should potentially contain highlighting (depends on Highlighting implementation) + } + + @Test + fun `formatBody should handle XML content`() { + // Given + val xmlBody = """John""" + + // When + val result = httpFormatterWithColor.formatBody(xmlBody, ContentType.Application.Xml) + + // Then + assertThat(result).contains("John") + assertThat(result).contains("user") + } + + @Test + fun `formatBody should handle plain text content`() { + // Given + val textBody = "Hello, World!" + + // When + val result = httpFormatterWithColor.formatBody(textBody, ContentType.Text.Plain) + + // Then + assertThat(result).contains("Hello, World!") + } + + @Test + fun `formatBody should handle large body content`() { + // Given + val largeBody = "x".repeat(10000) + + // When + val result = httpFormatterWithColor.formatBody(largeBody, ContentType.Text.Plain) + + // Then + assertThat(result).isNotNull() + assertThat(result.length).isGreaterThan(0) + } + + @Test + fun `formatBody should handle body with special characters`() { + // Given + val bodyWithSpecialChars = """{"message":"Hello 🌍! Special chars: äöü ñ ©®™"}""" + + // When + val result = httpFormatterWithColor.formatBody(bodyWithSpecialChars, ContentType.Application.Json) + + // Then + assertThat(result).contains("🌍") + assertThat(result).contains("äöü") + assertThat(result).contains("©®™") + } + + @Test + fun `formatRequest should format complete request correctly`() = runTest { + // Given + val mockCall = mockk() + val mockRequest = mockk() + + every { mockRequest.call } returns mockCall + every { mockRequest.httpMethod } returns HttpMethod.Post + every { mockRequest.uri } returns "/api/users" + every { mockRequest.headers.entries() } returns setOf( + object : Map.Entry> { + override val key = "Content-Type" + override val value = listOf("application/json") + }, + object : Map.Entry> { + override val key = "Authorization" + override val value = listOf("Bearer token123") + } + ) + coEvery { mockCall.receiveText() } returns """{"name":"John","age":30}""" + every { mockRequest.contentType() } returns ContentType.Application.Json + + // When + val result = httpFormatterWithColor.formatRequest(mockRequest) + + // Then + assertThat(result).isNotNull() + assertThat(result).contains("POST") + assertThat(result).contains("/api/users") + assertThat(result).contains("Content-Type") + assertThat(result).contains("application/json") + assertThat(result).contains("Authorization") + assertThat(result).contains("Bearer token123") + assertThat(result).contains(""""name":"John"""") + assertThat(result).contains(""""age":30""") + } + + @Test + fun `formatRequest should handle request with no headers`() = runTest { + // Given + val mockCall = mockk() + val mockRequest = mockk() + + every { mockRequest.call } returns mockCall + every { mockRequest.httpMethod } returns HttpMethod.Get + every { mockRequest.uri } returns "/api/users" + every { mockRequest.headers.entries() } returns emptySet() + coEvery { mockCall.receiveText() } returns "" + every { mockRequest.contentType() } returns null + + // When + val result = httpFormatterWithColor.formatRequest(mockRequest) + + // Then + assertThat(result).contains("GET") + assertThat(result).contains("/api/users") + } + + @Test + fun `formatRequest should handle request with empty body`() = runTest { + // Given + val mockCall = mockk() + val mockRequest = mockk() + + every { mockRequest.call } returns mockCall + every { mockRequest.httpMethod } returns HttpMethod.Get + every { mockRequest.uri } returns "/api/users" + every { mockRequest.headers.entries() } returns emptySet() + coEvery { mockCall.receiveText() } returns "" + every { mockRequest.contentType() } returns null + + // When + val result = httpFormatterWithColor.formatRequest(mockRequest) + + // Then + assertThat(result).contains("GET") + assertThat(result).contains("/api/users") + // Should handle empty body gracefully + } + + @Test + fun `formatRequest should handle concurrent requests safely`() = runTest { + // Given + val requests = (1..5).map { i -> + val mockCall = mockk() + val mockRequest = mockk() + + every { mockRequest.call } returns mockCall + every { mockRequest.httpMethod } returns HttpMethod.Get + every { mockRequest.uri } returns "/api/users/$i" + every { mockRequest.headers.entries() } returns setOf( + object : Map.Entry> { + override val key = "X-Request-ID" + override val value = listOf("req-$i") + } + ) + coEvery { mockCall.receiveText() } returns "" + every { mockRequest.contentType() } returns null + + mockRequest + } + + // When + val results = requests.map { httpFormatterWithColor.formatRequest(it) } + + // Then + results.forEachIndexed { index, result -> + assertThat(result).contains("GET") + assertThat(result).contains("/api/users/${index + 1}") + assertThat(result).contains("X-Request-ID") + assertThat(result).contains("req-${index + 1}") + } + } + + @Test + fun `isColorSupported should return boolean value on JVM`() { + // When + val result = isColorSupported() + + // Then + assertThat(result).isInstanceOf(Boolean::class.java) + } + + @Test + fun `colorize should return colorized text when enabled`() { + // Given + val text = "Hello World" + val color = AnsiColor.RED + + // When + val result = colorize(text, color, enabled = true) + + // Then + assertThat(result).startsWith(color.code) + assertThat(result).endsWith(AnsiColor.RESET.code) + assertThat(result).contains(text) + } + + @Test + fun `colorize should return original text when disabled`() { + // Given + val text = "Hello World" + val color = AnsiColor.RED + + // When + val result = colorize(text, color, enabled = false) + + // Then + assertThat(result).isEqualTo(text) + assertThat(result).doesNotContain(color.code) + assertThat(result).doesNotContain(AnsiColor.RESET.code) + } + + @ParameterizedTest + @EnumSource(AnsiColor::class) + fun `colorize should handle all ANSI colors correctly`(color: AnsiColor) { + // Given + val text = "Test" + + // When + val result = colorize(text, color, enabled = true) + + // Then + assertThat(result).contains(color.code) + assertThat(result).contains(text) + assertThat(result).endsWith(AnsiColor.RESET.code) + } + + @Test + fun `formatter should handle malformed JSON gracefully`() { + // Given + val malformedJson = """{"name":"John","age":}""" + + // When + val result = httpFormatterWithColor.formatBody(malformedJson, ContentType.Application.Json) + + // Then + assertThat(result).isNotNull() + assertThat(result).contains("John") + } + + @Test + fun `formatter should preserve exact formatting without modification`() { + // Given + val jsonWithFormatting = """{ + "name": "John", + "age": 30, + "address": { + "street": "123 Main St", + "city": "New York" + } +}""" + + // When + val result = httpFormatterWithoutColor.formatBody(jsonWithFormatting, ContentType.Application.Json) + + // Then + assertThat(result).isEqualTo(jsonWithFormatting) + } +} \ No newline at end of file