test/(http4s): add comprehensive Http4sCallContextBuilder unit tests

- Add Http4sCallContextBuilderTest with 454 lines of test coverage
- Test URL extraction including path, query parameters, and path parameters
- Test header extraction and conversion to HTTPParam format
- Test body extraction for POST, PUT, and GET requests
- Test correlation ID generation and extraction from X-Request-ID header
- Test IP address extraction from X-Forwarded-For and direct connection
- Test authentication header extraction for all supported auth types
- Test error handling and edge cases in CallContext building
- Ensure Http4sCallContextBuilder correctly processes http4s Request[IO] objects
This commit is contained in:
hongwei 2026-01-23 12:21:18 +01:00
parent 3cb783affa
commit f0eaedaf3a

View File

@ -0,0 +1,454 @@
package code.api.util.http4s
import cats.effect.IO
import cats.effect.unsafe.implicits.global
import net.liftweb.common.{Empty, Full}
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.headers.`Content-Type`
import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers, Tag}
/**
* Unit tests for Http4sCallContextBuilder
*
* Tests CallContext building from http4s Request[IO]:
* - URL extraction (including query parameters)
* - Header extraction and conversion to HTTPParam
* - Body extraction for POST requests
* - Correlation ID generation/extraction
* - IP address extraction (X-Forwarded-For and direct)
* - Auth header extraction for all auth types
*
*/
class Http4sCallContextBuilderTest extends FeatureSpec with Matchers with GivenWhenThen {
object Http4sCallContextBuilderTag extends Tag("Http4sCallContextBuilder")
feature("Http4sCallContextBuilder - URL extraction") {
scenario("Extract URL with path only", Http4sCallContextBuilderTag) {
Given("A request with path /obp/v7.0.0/banks")
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("URL should match the request URI")
callContext.url should equal("/obp/v7.0.0/banks")
}
scenario("Extract URL with query parameters", Http4sCallContextBuilderTag) {
Given("A request with query parameters")
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks?limit=10&offset=0")
)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("URL should include query parameters")
callContext.url should equal("/obp/v7.0.0/banks?limit=10&offset=0")
}
scenario("Extract URL with path parameters", Http4sCallContextBuilderTag) {
Given("A request with path parameters")
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1")
)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("URL should include path parameters")
callContext.url should equal("/obp/v7.0.0/banks/gh.29.de/accounts/test1")
}
}
feature("Http4sCallContextBuilder - Header extraction") {
scenario("Extract headers and convert to HTTPParam", Http4sCallContextBuilderTag) {
Given("A request with multiple headers")
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
).withHeaders(
Header.Raw(org.typelevel.ci.CIString("Content-Type"), "application/json"),
Header.Raw(org.typelevel.ci.CIString("Accept"), "application/json"),
Header.Raw(org.typelevel.ci.CIString("X-Custom-Header"), "test-value")
)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("Headers should be converted to HTTPParam list")
callContext.requestHeaders should not be empty
callContext.requestHeaders.exists(_.name == "Content-Type") should be(true)
callContext.requestHeaders.exists(_.name == "Accept") should be(true)
callContext.requestHeaders.exists(_.name == "X-Custom-Header") should be(true)
}
scenario("Extract empty headers list", Http4sCallContextBuilderTag) {
Given("A request with no custom headers")
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("Headers list should be empty or contain only default headers")
// http4s may add default headers, so we just check it's a list
callContext.requestHeaders should be(a[List[_]])
}
}
feature("Http4sCallContextBuilder - Body extraction") {
scenario("Extract body from POST request", Http4sCallContextBuilderTag) {
Given("A POST request with JSON body")
val jsonBody = """{"name": "Test Bank", "id": "test-bank-1"}"""
val request = Request[IO](
method = Method.POST,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
).withEntity(jsonBody)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("Body should be extracted as Some(string)")
callContext.httpBody should be(Some(jsonBody))
}
scenario("Extract empty body from GET request", Http4sCallContextBuilderTag) {
Given("A GET request with no body")
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("Body should be None")
callContext.httpBody should be(None)
}
scenario("Extract body from PUT request", Http4sCallContextBuilderTag) {
Given("A PUT request with JSON body")
val jsonBody = """{"name": "Updated Bank"}"""
val request = Request[IO](
method = Method.PUT,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks/test-bank-1")
).withEntity(jsonBody)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("Body should be extracted")
callContext.httpBody should be(Some(jsonBody))
}
}
feature("Http4sCallContextBuilder - Correlation ID") {
scenario("Extract correlation ID from X-Request-ID header", Http4sCallContextBuilderTag) {
Given("A request with X-Request-ID header")
val requestId = "test-correlation-id-12345"
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
).withHeaders(
Header.Raw(org.typelevel.ci.CIString("X-Request-ID"), requestId)
)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("Correlation ID should match the header value")
callContext.correlationId should equal(requestId)
}
scenario("Generate correlation ID when header missing", Http4sCallContextBuilderTag) {
Given("A request without X-Request-ID header")
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("Correlation ID should be generated (UUID format)")
callContext.correlationId should not be empty
// UUID format: 8-4-4-4-12 hex digits
callContext.correlationId should fullyMatch regex "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
}
}
feature("Http4sCallContextBuilder - IP address extraction") {
scenario("Extract IP from X-Forwarded-For header", Http4sCallContextBuilderTag) {
Given("A request with X-Forwarded-For header")
val clientIp = "192.168.1.100"
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
).withHeaders(
Header.Raw(org.typelevel.ci.CIString("X-Forwarded-For"), clientIp)
)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("IP address should match the header value")
callContext.ipAddress should equal(clientIp)
}
scenario("Extract first IP from X-Forwarded-For with multiple IPs", Http4sCallContextBuilderTag) {
Given("A request with X-Forwarded-For containing multiple IPs")
val forwardedFor = "192.168.1.100, 10.0.0.1, 172.16.0.1"
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
).withHeaders(
Header.Raw(org.typelevel.ci.CIString("X-Forwarded-For"), forwardedFor)
)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("IP address should be the first IP in the list")
callContext.ipAddress should equal("192.168.1.100")
}
scenario("Handle missing IP address", Http4sCallContextBuilderTag) {
Given("A request without X-Forwarded-For or remote address")
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("IP address should be empty string")
callContext.ipAddress should equal("")
}
}
feature("Http4sCallContextBuilder - Authentication header extraction") {
scenario("Extract DirectLogin token from DirectLogin header (new format)", Http4sCallContextBuilderTag) {
Given("A request with DirectLogin header")
val token = "eyJhbGciOiJIUzI1NiJ9.eyIiOiIifQ.test"
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
).withHeaders(
Header.Raw(org.typelevel.ci.CIString("DirectLogin"), s"token=$token")
)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("DirectLogin params should contain token")
callContext.directLoginParams should contain key "token"
callContext.directLoginParams("token") should equal(token)
}
scenario("Extract DirectLogin token from Authorization header (old format)", Http4sCallContextBuilderTag) {
Given("A request with Authorization: DirectLogin header")
val token = "eyJhbGciOiJIUzI1NiJ9.eyIiOiIifQ.test"
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
).withHeaders(
Header.Raw(org.typelevel.ci.CIString("Authorization"), s"DirectLogin token=$token")
)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("DirectLogin params should contain token")
callContext.directLoginParams should contain key "token"
callContext.directLoginParams("token") should equal(token)
And("Authorization header should be stored")
callContext.authReqHeaderField should equal(Full(s"DirectLogin token=$token"))
}
scenario("Extract DirectLogin with username and password", Http4sCallContextBuilderTag) {
Given("A request with DirectLogin username and password")
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
).withHeaders(
Header.Raw(org.typelevel.ci.CIString("DirectLogin"), """username="testuser", password="testpass", consumer_key="key123"""")
)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("DirectLogin params should contain all parameters")
callContext.directLoginParams should contain key "username"
callContext.directLoginParams should contain key "password"
callContext.directLoginParams should contain key "consumer_key"
callContext.directLoginParams("username") should equal("testuser")
callContext.directLoginParams("password") should equal("testpass")
callContext.directLoginParams("consumer_key") should equal("key123")
}
scenario("Extract OAuth parameters from Authorization header", Http4sCallContextBuilderTag) {
Given("A request with OAuth Authorization header")
val oauthHeader = """OAuth oauth_consumer_key="consumer123", oauth_token="token456", oauth_signature="sig789""""
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
).withHeaders(
Header.Raw(org.typelevel.ci.CIString("Authorization"), oauthHeader)
)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("OAuth params should be extracted")
callContext.oAuthParams should contain key "oauth_consumer_key"
callContext.oAuthParams should contain key "oauth_token"
callContext.oAuthParams should contain key "oauth_signature"
callContext.oAuthParams("oauth_consumer_key") should equal("consumer123")
callContext.oAuthParams("oauth_token") should equal("token456")
callContext.oAuthParams("oauth_signature") should equal("sig789")
And("Authorization header should be stored")
callContext.authReqHeaderField should equal(Full(oauthHeader))
}
scenario("Extract Bearer token from Authorization header", Http4sCallContextBuilderTag) {
Given("A request with Bearer token")
val bearerToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.signature"
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
).withHeaders(
Header.Raw(org.typelevel.ci.CIString("Authorization"), s"Bearer $bearerToken")
)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("Authorization header should be stored")
callContext.authReqHeaderField should equal(Full(s"Bearer $bearerToken"))
}
scenario("Handle missing Authorization header", Http4sCallContextBuilderTag) {
Given("A request without Authorization header")
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("Auth header field should be Empty")
callContext.authReqHeaderField should equal(Empty)
And("DirectLogin params should be empty")
callContext.directLoginParams should be(empty)
And("OAuth params should be empty")
callContext.oAuthParams should be(empty)
}
}
feature("Http4sCallContextBuilder - Request metadata") {
scenario("Extract HTTP verb", Http4sCallContextBuilderTag) {
Given("A POST request")
val request = Request[IO](
method = Method.POST,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("Verb should be POST")
callContext.verb should equal("POST")
}
scenario("Set implementedInVersion from parameter", Http4sCallContextBuilderTag) {
Given("A request with API version v7.0.0")
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
)
When("Building CallContext with version parameter")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("implementedInVersion should match the parameter")
callContext.implementedInVersion should equal("v7.0.0")
}
scenario("Set startTime to current date", Http4sCallContextBuilderTag) {
Given("A request")
val request = Request[IO](
method = Method.GET,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks")
)
When("Building CallContext")
val beforeTime = new java.util.Date()
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
val afterTime = new java.util.Date()
Then("startTime should be set and within reasonable range")
callContext.startTime should be(defined)
callContext.startTime.get.getTime should be >= beforeTime.getTime
callContext.startTime.get.getTime should be <= afterTime.getTime
}
}
feature("Http4sCallContextBuilder - Complete integration") {
scenario("Build complete CallContext with all fields", Http4sCallContextBuilderTag) {
Given("A complete POST request with all headers and body")
val jsonBody = """{"name": "Test Bank"}"""
val token = "test-token-123"
val correlationId = "correlation-123"
val clientIp = "192.168.1.100"
val request = Request[IO](
method = Method.POST,
uri = Uri.unsafeFromString("/obp/v7.0.0/banks?limit=10")
).withHeaders(
Header.Raw(org.typelevel.ci.CIString("Content-Type"), "application/json"),
Header.Raw(org.typelevel.ci.CIString("DirectLogin"), s"token=$token"),
Header.Raw(org.typelevel.ci.CIString("X-Request-ID"), correlationId),
Header.Raw(org.typelevel.ci.CIString("X-Forwarded-For"), clientIp)
).withEntity(jsonBody)
When("Building CallContext")
val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync()
Then("All fields should be populated correctly")
callContext.url should equal("/obp/v7.0.0/banks?limit=10")
callContext.verb should equal("POST")
callContext.implementedInVersion should equal("v7.0.0")
callContext.correlationId should equal(correlationId)
callContext.ipAddress should equal(clientIp)
callContext.httpBody should be(Some(jsonBody))
callContext.directLoginParams should contain key "token"
callContext.directLoginParams("token") should equal(token)
callContext.requestHeaders should not be empty
callContext.startTime should be(defined)
}
}
}