From ed61c86eac0b3ef8f8dae6de1854c037402982b5 Mon Sep 17 00:00:00 2001 From: hongwei Date: Thu, 5 Feb 2026 01:21:38 +0100 Subject: [PATCH] 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 --- .../Http4sLiftBridgeParityTest.scala | 152 +++++++++++------- 1 file changed, 90 insertions(+), 62 deletions(-) diff --git a/obp-api/src/test/scala/code/api/http4sbridge/Http4sLiftBridgeParityTest.scala b/obp-api/src/test/scala/code/api/http4sbridge/Http4sLiftBridgeParityTest.scala index f0349e61a..61d37ee4c 100644 --- a/obp-api/src/test/scala/code/api/http4sbridge/Http4sLiftBridgeParityTest.scala +++ b/obp-api/src/test/scala/code/api/http4sbridge/Http4sLiftBridgeParityTest.scala @@ -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