test/(Http4sLiftBridgeParityTest): migrate to network-based Http4sTestServer

- Replace in-process bridge initialization with Http4sTestServer singleton reference
- Switch from http4s Request/Response types to dispatch library for HTTP calls
- Implement makeHttp4sGetRequest and makeHttp4sPostRequest using dispatch for network requests
- Update request/response handling to work with HTTP status codes and header maps
- Refactor assertCorrelationId to work with Map[String, String] instead of http4s Headers
- Add comprehensive documentation explaining the test's purpose and architecture
- Remove LiftRules inconsistency workarounds by testing against real HTTP4S server
- Update imports to use dispatch, scala concurrent utilities, and remove http4s internals
- This change ensures parity testing matches production behavior by testing over the network
This commit is contained in:
hongwei 2026-02-05 01:21:38 +01:00
parent e885d3f504
commit ed61c86eac

View File

@ -1,31 +1,37 @@
package code.api.http4sbridge
import cats.effect.IO
import cats.effect.unsafe.implicits.global
import code.Http4sTestServer
import code.api.ResponseHeader
import code.api.v5_0_0.V500ServerSetup
import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.createSystemViewJsonV500
import code.api.v5_0_0.ViewJsonV500
import code.api.berlin.group.ConstantsBG
import code.api.util.APIUtil
import code.api.util.APIUtil.OAuth._
import code.api.util.ApiRole.{CanCreateSystemView, CanDeleteSystemView, CanGetSystemView, CanUpdateSystemView}
import code.api.util.http4s.Http4sLiftWebBridge
import code.consumer.Consumers
import code.entitlement.Entitlement
import code.model.dataAccess.AuthUser
import code.views.system.AccountAccess
import com.openbankproject.commons.model.UpdateViewJSON
import dispatch.Defaults._
import dispatch._
import net.liftweb.json.JValue
import net.liftweb.json.JsonAST.JObject
import net.liftweb.json.JsonParser.parse
import net.liftweb.json.Serialization.write
import net.liftweb.mapper.By
import net.liftweb.util.Helpers._
import org.http4s.{Header, Headers, Method, Request, Status, Uri}
import org.scalatest.Tag
import org.typelevel.ci.CIString
import scala.collection.JavaConverters._
import scala.concurrent.Await
import scala.concurrent.duration.DurationInt
/**
* Http4s Lift Bridge Parity Test
*
* This test verifies that the HTTP4S server (via Http4sTestServer) produces
* responses that match the Lift/Jetty server responses across different API versions
* and authentication methods.
*
* Unlike the previous implementation that ran the bridge in-process (which had
* LiftRules inconsistency issues), this test uses Http4sTestServer to test the
* real HTTP4S server over the network, matching production behavior.
*/
class Http4sLiftBridgeParityTest extends V500ServerSetup {
// Create a test user with known password for DirectLogin testing
@ -34,21 +40,13 @@ class Http4sLiftBridgeParityTest extends V500ServerSetup {
private val testConsumerKey = randomString(40).toLowerCase
private val testConsumerSecret = randomString(40).toLowerCase
// Initialize http4sRoutes after Lift is fully initialized
// NOTE: This test has a known limitation - it runs the bridge in the test process,
// which has a separate LiftRules instance from the Jetty server process.
// The Jetty server (accessed via makePostRequest) has all routes registered,
// but the bridge in the test process may not have access to the same routes.
// In production (Http4sServer), the bridge runs in the same process as Lift initialization,
// so this issue does not occur.
private var http4sRoutes: org.http4s.HttpApp[IO] = _
// Reference the singleton HTTP4S test server (auto-starts on first access)
private val http4sServer = Http4sTestServer
private val http4sBaseUrl = s"http://${http4sServer.host}:${http4sServer.port}"
override def beforeAll(): Unit = {
super.beforeAll()
// Initialize http4sRoutes AFTER Lift has been fully initialized by super.beforeAll()
http4sRoutes = Http4sLiftWebBridge.withStandardHeaders(Http4sLiftWebBridge.routes).orNotFound
// Create AuthUser if not exists
if (AuthUser.find(By(AuthUser.username, testUsername)).isEmpty) {
AuthUser.create
@ -85,23 +83,53 @@ class Http4sLiftBridgeParityTest extends V500ServerSetup {
object Http4sLiftBridgeParityTag extends Tag("Http4sLiftBridgeParity")
private def toHttp4sRequest(reqData: ReqData): Request[IO] = {
val method = Method.fromString(reqData.method).getOrElse(Method.GET)
val base = Request[IO](method = method, uri = Uri.unsafeFromString(reqData.url))
// Set body first
val withBody = if (reqData.body.trim.nonEmpty) base.withEntity(reqData.body) else base
// Then set headers (including Content-Type) to override defaults
val withHeaders = reqData.headers.foldLeft(withBody) { case (req, (key, value)) =>
req.putHeaders(Header.Raw(CIString(key), value))
private def makeHttp4sGetRequest(path: String, headers: Map[String, String] = Map.empty): (Int, JValue, Map[String, String]) = {
val request = url(s"$http4sBaseUrl$path")
val requestWithHeaders = headers.foldLeft(request) { case (req, (key, value)) =>
req.addHeader(key, value)
}
try {
val response = Http.default(requestWithHeaders.setHeader("Accept", "*/*") > as.Response(p => {
val statusCode = p.getStatusCode
val body = if (p.getResponseBody != null && p.getResponseBody.trim.nonEmpty) p.getResponseBody else "{}"
val json = parse(body)
val responseHeaders = p.getHeaders.iterator().asScala.map(e => e.getKey -> e.getValue).toMap
(statusCode, json, responseHeaders)
}))
Await.result(response, DurationInt(10).seconds)
} catch {
case e: java.util.concurrent.ExecutionException =>
// Extract status code from exception message if possible
val statusPattern = """(\d{3})""".r
statusPattern.findFirstIn(e.getCause.getMessage) match {
case Some(code) => (code.toInt, JObject(Nil), Map.empty)
case None => throw e
}
case e: Exception =>
throw e
}
withHeaders
}
private def runHttp4s(reqData: ReqData): (Status, JValue, Headers) = {
val response = http4sRoutes.run(toHttp4sRequest(reqData)).unsafeRunSync()
val body = response.as[String].unsafeRunSync()
val json = if (body.trim.isEmpty) JObject(Nil) else parse(body)
(response.status, json, response.headers)
private def makeHttp4sPostRequest(path: String, body: String, headers: Map[String, String] = Map.empty): (Int, JValue, Map[String, String]) = {
val request = url(s"$http4sBaseUrl$path").POST.setBody(body)
val requestWithHeaders = headers.foldLeft(request) { case (req, (key, value)) =>
req.addHeader(key, value)
}
try {
val response = Http.default(requestWithHeaders.setHeader("Accept", "*/*") > as.Response(p => {
val statusCode = p.getStatusCode
val responseBody = if (p.getResponseBody != null && p.getResponseBody.trim.nonEmpty) p.getResponseBody else "{}"
val json = parse(responseBody)
val responseHeaders = p.getHeaders.iterator().asScala.map(e => e.getKey -> e.getValue).toMap
(statusCode, json, responseHeaders)
}))
Await.result(response, DurationInt(10).seconds)
} catch {
case e: Exception =>
throw e
}
}
private def hasField(json: JValue, key: String): Boolean = {
@ -122,10 +150,10 @@ class Http4sLiftBridgeParityTest extends V500ServerSetup {
jsonKeys(json).map(_.toLowerCase)
}
private def assertCorrelationId(headers: Headers): Unit = {
val header = headers.headers.find(_.name.toString.equalsIgnoreCase(ResponseHeader.`Correlation-Id`))
private def assertCorrelationId(headers: Map[String, String]): Unit = {
val header = headers.find { case (key, _) => key.equalsIgnoreCase(ResponseHeader.`Correlation-Id`) }
header.isDefined shouldBe true
header.map(_.value.trim.nonEmpty).getOrElse(false) shouldBe true
header.map(_._2.trim.nonEmpty).getOrElse(false) shouldBe true
}
private val standardVersions = List(
@ -148,10 +176,9 @@ class Http4sLiftBridgeParityTest extends V500ServerSetup {
private def runBanksParity(version: String): Unit = {
val liftReq = (baseRequest / "obp" / version / "banks").GET
val liftResponse = makeGetRequest(liftReq)
val reqData = extractParamsAndHeaders(liftReq, "", "")
val (http4sStatus, http4sJson, http4sHeaders) = runHttp4s(reqData)
val (http4sStatus, http4sJson, http4sHeaders) = makeHttp4sGetRequest(s"/obp/$version/banks")
http4sStatus.code should equal(liftResponse.code)
http4sStatus should equal(liftResponse.code)
jsonKeysLower(http4sJson) should equal(jsonKeysLower(liftResponse.body))
assertCorrelationId(http4sHeaders)
}
@ -160,9 +187,12 @@ class Http4sLiftBridgeParityTest extends V500ServerSetup {
val liftReq = (baseRequest / "open-banking" / version / "accounts").GET <@(user1)
val liftResponse = makeGetRequest(liftReq)
val reqData = extractParamsAndHeaders(liftReq, "", "")
val (http4sStatus, _, http4sHeaders) = runHttp4s(reqData)
val (http4sStatus, _, http4sHeaders) = makeHttp4sGetRequest(
s"/open-banking/$version/accounts",
reqData.headers
)
http4sStatus.code should equal(liftResponse.code)
http4sStatus should equal(liftResponse.code)
assertCorrelationId(http4sHeaders)
}
@ -185,9 +215,13 @@ class Http4sLiftBridgeParityTest extends V500ServerSetup {
val liftReq = (base / "accounts").GET <@(user1)
val liftResponse = makeGetRequest(liftReq)
val reqData = extractParamsAndHeaders(liftReq, "", "")
val (http4sStatus, http4sJson, http4sHeaders) = runHttp4s(reqData)
val berlinPathStr = berlinPath.mkString("/", "/", "")
val (http4sStatus, http4sJson, http4sHeaders) = makeHttp4sGetRequest(
s"$berlinPathStr/accounts",
reqData.headers
)
http4sStatus.code should equal(liftResponse.code)
http4sStatus should equal(liftResponse.code)
// Berlin Group responses can differ in top-level keys while still being valid.
assertCorrelationId(http4sHeaders)
}
@ -195,10 +229,9 @@ class Http4sLiftBridgeParityTest extends V500ServerSetup {
scenario("DirectLogin parity - missing auth header", Http4sLiftBridgeParityTag) {
val liftReq = (baseRequest / "my" / "logins" / "direct").POST
val liftResponse = makePostRequest(liftReq, "")
val reqData = extractParamsAndHeaders(liftReq, "", "")
val (http4sStatus, http4sJson, http4sHeaders) = runHttp4s(reqData)
val (http4sStatus, http4sJson, http4sHeaders) = makeHttp4sPostRequest("/my/logins/direct", "")
http4sStatus.code should equal(liftResponse.code)
http4sStatus should equal(liftResponse.code)
(hasField(http4sJson, "error") || hasField(http4sJson, "message")) shouldBe true
assertCorrelationId(http4sHeaders)
}
@ -213,24 +246,19 @@ class Http4sLiftBridgeParityTest extends V500ServerSetup {
val liftResponse = makePostRequest(liftReq, "")
val reqData = ReqData(
url = s"http://${server.host}:${server.port}/my/logins/direct",
method = "POST",
body = "",
body_encoding = "UTF-8",
headers = Map(
val (http4sStatus, http4sJson, http4sHeaders) = makeHttp4sPostRequest(
"/my/logins/direct",
"",
Map(
"Authorization" -> directLoginHeader,
"Content-Type" -> "application/json"
),
query_params = Map.empty,
form_params = Map.empty
)
)
val (http4sStatus, http4sJson, http4sHeaders) = runHttp4s(reqData)
// Both should return 201 Created
liftResponse.code should equal(201)
http4sStatus.code should equal(201)
http4sStatus.code should equal(liftResponse.code)
http4sStatus should equal(201)
http4sStatus should equal(liftResponse.code)
// Both should have a token field
hasField(http4sJson, "token") shouldBe true