ktorio / ktor

Framework for quickly creating connected applications in Kotlin with minimal effort
https://ktor.io
Apache License 2.0
13.09k stars 1.07k forks source link

How to test POST request? #1807

Closed LookBad closed 3 years ago

LookBad commented 4 years ago

Hi!

I wrote a test:

object AdminAuthHandlerTest : Spek({
    with(ktorTestEngine()) {
        describe("A sign-in endpoint") {
            lateinit var call: TestApplicationCall
            beforeGroup {
                call = handleRequest(HttpMethod.Post, "/api/admin/auth/sign-in") {
                    addHeader(HttpHeaders.Accept, ContentType.Text.Plain.contentType)
                    addHeader(HttpHeaders.ContentType, ContentType.Application.Json.contentType)
                    setBody(jsonAsString(AdminSignInRequest("admin", "admin123")))
                }
            }
            it("should return 200") {
                println("${call.request.bodyChannel.asString()} ${call.requestHandled}")
                assertThat(call.response.status()).isEqualTo(HttpStatusCode.OK)
            }
            it("should return token in response body") {
                assertThat(call.response.content).contains("token")
            }
            it("should return profile object in response body") {
                assertThat(call.response.content).contains("profile")
            }
            it("should set admin in call") {
                assertThat(call.admin).isNotNull()
            }
        }
    }
})

My test fails. In response, I always get 400 code, but I provide correct data in setBody method. When I do the request from Postman (or my client app) with the same data it works correctly.

fun jsonAsString(any: Any): String {
    return Gson().toJson(any)
}

I cannot find any information about a similar situation in docs. Do you have any idea what do I wrong?

LookBad commented 4 years ago

Do you know where is problem?

dmitrievanthony commented 4 years ago

Hi @LookBad, could you please tell me the Ktor version and provide a reproducible snippet? I'm not sure I'm aware of the way you create the application call.

LookBad commented 4 years ago

Ok. Catch up: https://gist.github.com/LookBad/89e77c6f5a45fbe386ae89115366cfe0 if you need more files, just let me know.

Body of post method in adminAuthHandler can have only val request = call.receive<AdminSignInRequest>() to get this error.

LookBad commented 4 years ago

@dmitrievanthony what's up? Did you reproduce my error?

dmitrievanthony commented 4 years ago

Hi @LookBad, could you also provide the ktorTestEngine function? I can't reproduce the issue without it.

dmitrievanthony commented 4 years ago

Well, @LookBad , I definitely need to get the ktorTestEngine method because the following code works fine:

@KtorExperimentalAPI
@KtorExperimentalLocationsAPI
object AdminAuthHandlerTest : Spek({
    val engine = TestApplicationEngine(createTestEnvironment())
    engine.start()
    engine.application.install(Locations)
    engine.application.routing {
        post<Admin.Auth.SignIn> {
            call.respondText("OK")
        }
    }
    with(engine) {
        describe("A sign-in endpoint") {
            lateinit var call: TestApplicationCall
            beforeGroup {
                call = handleRequest(HttpMethod.Post, "/admin/auth/sign-in") {
                    addHeader(HttpHeaders.Accept, ContentType.Text.Plain.contentType)
                    addHeader(HttpHeaders.ContentType, ContentType.Application.Json.contentType)
                    setBody(jsonAsString(AdminSignInRequest("admin", "admin123")))
                }
            }
            it("should return 200") {
                assertThat(call.response.status()).isEqualTo(HttpStatusCode.OK)
            }
        }
    }
})
LookBad commented 4 years ago

Ok

fun ktorTestEngine() =
    TestApplicationEngine(createTestEnvironment()).apply {
        start(wait = false)
        application.parkkometrModule(true)
    }
dmitrievanthony commented 4 years ago

It looks like parkkometrModule is also some extension function, isn't it?

LookBad commented 4 years ago

It'is my application module.

@KtorExperimentalAPI
@KtorExperimentalLocationsAPI
@Suppress("unused", "UNUSED_PARAMETER")
fun Application.parkkometrModule(testing: Boolean = false) {
    install(AutoHeadResponse)
    install(Locations)
    install(CORS) {
        anyHost() // @TODO: Don't do this in production if possible. Try to limit it.
        method(HttpMethod.Options)
        method(HttpMethod.Put)
        method(HttpMethod.Post)
        method(HttpMethod.Delete)
        method(HttpMethod.Patch)
        header(HttpHeaders.Authorization)
        allowCredentials = true
        allowNonSimpleContentTypes = true
    }
    install(ContentNegotiation) {
        jackson {
            enable(SerializationFeature.INDENT_OUTPUT)
            dateFormat = DateFormat.getDateInstance()
            findAndRegisterModules()
        }
    }
    install(Authentication) {
        jwt {
            verifier(JwtConfig.verifier)
            realm = "ktor.io"
            validate {
                val accountType = it.payload.getClaim("type").asInt()
                val accountId = it.payload.getClaim("id").asInt()
                if (accountType == Authenticable.TYPE_ADMIN)
                    accountId.let(AdminService()::findAdminById)
                else
                    accountId.let(UserService()::findUserById)
            }
        }
    }
    install(StatusPages) {
        exceptionHandler(this)
    }

    connectWithDatabase()
    routing {
        route("api/") {
            adminAuthHandler()
            userAuthHandler()
            authenticate {
                adminManageHandler()
                profileHandler()
                parkingHandler() }
        }
    }
}

I replaced gson with Jackson.

dmitrievanthony commented 4 years ago

Well, @LookBad, I used your code and commented the lines I have no source to resolve. The result is here:

package test

import com.fasterxml.jackson.databind.*
import com.google.gson.*
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.auth.jwt.*
import io.ktor.features.*
import io.ktor.http.*
import io.ktor.jackson.*
import io.ktor.locations.*
import io.ktor.routing.*
import io.ktor.server.testing.*
import io.ktor.util.*
import org.assertj.core.api.Assertions.assertThat
import org.spekframework.spek2.*
import org.spekframework.spek2.style.specification.*
import java.text.*

@KtorExperimentalAPI
@KtorExperimentalLocationsAPI
object AdminAuthHandlerTest : Spek({
    with(ktorTestEngine()) {
        describe("A sign-in endpoint") {
            lateinit var call: TestApplicationCall
            beforeGroup {
                call = handleRequest(HttpMethod.Post, "/api/admin/auth/sign-in") {
                    addHeader(HttpHeaders.Accept, ContentType.Text.Plain.contentType)
                    addHeader(HttpHeaders.ContentType, ContentType.Application.Json.contentType)
                    setBody(jsonAsString(AdminSignInRequest("admin", "admin123")))
                }
            }
            it("should return 200") {
                println("${call.request.bodyChannel} ${call.requestHandled}")
                assertThat(call.response.status()).isEqualTo(HttpStatusCode.OK)
            }
        }
    }
})

fun ktorTestEngine() =
        TestApplicationEngine(createTestEnvironment()).apply {
            start(wait = false)
            application.parkkometrModule(true)
        }

fun jsonAsString(any: Any): String {
    return Gson().toJson(any)
}

@KtorExperimentalAPI
@KtorExperimentalLocationsAPI
@Suppress("unused", "UNUSED_PARAMETER")
fun Application.parkkometrModule(testing: Boolean = false) {
    install(AutoHeadResponse)
    install(Locations)
    install(CORS) {
        anyHost() // @TODO: Don't do this in production if possible. Try to limit it.
        method(HttpMethod.Options)
        method(HttpMethod.Put)
        method(HttpMethod.Post)
        method(HttpMethod.Delete)
        method(HttpMethod.Patch)
        header(HttpHeaders.Authorization)
        allowCredentials = true
        allowNonSimpleContentTypes = true
    }
    install(ContentNegotiation) {
        jackson {
            enable(SerializationFeature.INDENT_OUTPUT)
            dateFormat = DateFormat.getDateInstance()
            findAndRegisterModules()
        }
    }
    install(Authentication) {
        jwt {
//            verifier(JwtConfig.verifier)
            realm = "ktor.io"
//            validate {
//                val accountType = it.payload.getClaim("type").asInt()
//                val accountId = it.payload.getClaim("id").asInt()
//                if (accountType == Authenticable.TYPE_ADMIN)
//                    accountId.let(AdminService()::findAdminById)
//                else
//                    accountId.let(UserService()::findUserById)
//            }
        }
    }
    install(StatusPages) {
//        exceptionHandler(this)
    }

//    connectWithDatabase()
    routing {
        route("api/") {
            adminAuthHandler()
//            userAuthHandler()
            authenticate {
//                adminManageHandler()
//                profileHandler()
//                parkingHandler()
            }
        }
    }
}

And it works perfectly well. I'm sure we are on the right way to solve the issue, but to speed the process up could you provide the minimal, complete and reproducible examples that demonstrates the issue?

LookBad commented 4 years ago

My app: Archive.zip. Ps. I know, I have to clean up the project.

LookBad commented 4 years ago

What's up?

LookBad commented 4 years ago

ping :)

LookBad commented 4 years ago

I have a better message error after I have changed the JRE:

Expecting:
 <400 Bad Request>
to be equal to:
 <200 OK>
but was not.
org.opentest4j.AssertionFailedError: 
Expecting:
 <400 Bad Request>
to be equal to:
 <200 OK>
but was not.
    at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
    at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
    at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
    at com.easythings.parkkometr.test.handler.api.AdminAuthHandlerTest$1$1$1$2.invoke(AdminAuthHandlerTest.kt:36)
    at com.easythings.parkkometr.test.handler.api.AdminAuthHandlerTest$1$1$1$2.invoke(AdminAuthHandlerTest.kt:23)
    at org.spekframework.spek2.runtime.scope.TestScopeImpl.execute(scopes.kt:136)
    at org.spekframework.spek2.runtime.Executor$execute$result$2$exception$1$job$1.invokeSuspend(Executor.kt:74)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:56)
    at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
    at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:738)
    at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
    at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)
LookBad commented 4 years ago

@dmitrievanthony what do you think about it?

Stexxe commented 4 years ago

@LookBad the root cause is BadContentTypeFormatException that is thrown, because of how you add Content-Type header in the app/test/src/handler/api/AdminAuthHandlerTest.kt:

addHeader(HttpHeaders.ContentType, ContentType.Application.Json.contentType)

contentType is a property of ContentType class that represents the first part of the media type — in your case application (from application/json). To fix this you can replace property access with toString() method call:

addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString())
LookBad commented 4 years ago

Wow! Thank you! I love it. It should be better documented.