From 8f232256c47958076cc47a67515082dbe667da3f Mon Sep 17 00:00:00 2001 From: Thomas Droxler Date: Mon, 17 Aug 2026 15:12:30 +0200 Subject: [PATCH 1/5] Add coverage badge --- .github/workflows/codecov.yaml | 42 ++++++++++++++++++++++++++++++++++ README.md | 4 ++++ 2 files changed, 46 insertions(+) create mode 100644 .github/workflows/codecov.yaml diff --git a/.github/workflows/codecov.yaml b/.github/workflows/codecov.yaml new file mode 100644 index 000000000..4006055a4 --- /dev/null +++ b/.github/workflows/codecov.yaml @@ -0,0 +1,42 @@ +name: Codecov + +on: + push: + branches: [ master ] + pull_request: + +jobs: + codecov: + runs-on: ubuntu-latest + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 (2025-11-17T15:57:55Z) + with: + fetch-depth: 0 + - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 (2025-12-04T03:00:25Z) + with: + distribution: temurin + java-version: 11 + cache: sbt + - name: Setup sbt launcher + uses: sbt/setup-sbt@3e125ece5c3e5248e18da9ed8d2cce3d335ec8dd # v1.1.14 (2025-10-05T20:19:35Z) + - name: Test with coverage + run: sbt coverage test coverageReport + - name: Upload coverage to Codecov + uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 (2025-09-04T14:36:56Z) + with: + name: codecov-explorer-backend + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: true diff --git a/README.md b/README.md index a72c67a4f..42de06421 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Alephium explorer backend +[![codecov][codecov-badge]][codecov-link] + Alephium's explorer backend is an indexer that provides a RESTful API to query the Alephium blockchain. It serves https://explorer.alephium.org/ as well as our wallets. @@ -180,6 +182,8 @@ sbt test [postgresql]: https://www.postgresql.org/ [sbt]: https://www.scala-sbt.org/ [bytea]: https://www.postgresql.org/docs/9.0/datatype-binary.html +[codecov-badge]: https://codecov.io/gh/alephium/explorer-backend/branch/master/graph/badge.svg +[codecov-link]: https://codecov.io/gh/alephium/explorer-backend ## Scaladoc From b92fe72dc077c6d061223f0e6bf8d34f7bbc9fb8 Mon Sep 17 00:00:00 2001 From: Thomas Droxler Date: Tue, 18 Aug 2026 09:49:06 +0200 Subject: [PATCH 2/5] Fix `TransactionServer` hex deserializing --- .../explorer/web/TransactionServer.scala | 10 ++-- .../explorer/web/TransactionServerSpec.scala | 46 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 app/src/test/scala/org/alephium/explorer/web/TransactionServerSpec.scala diff --git a/app/src/main/scala/org/alephium/explorer/web/TransactionServer.scala b/app/src/main/scala/org/alephium/explorer/web/TransactionServer.scala index bcd29f436..0e3e90424 100644 --- a/app/src/main/scala/org/alephium/explorer/web/TransactionServer.scala +++ b/app/src/main/scala/org/alephium/explorer/web/TransactionServer.scala @@ -69,8 +69,10 @@ class TransactionServer(implicit private def deserializeUnsignedTx( rawUtx: String ): Either[ApiError[_ <: StatusCode], protocol.model.UnsignedTransaction] = - deserialize[protocol.model.UnsignedTransaction]( - Hex.unsafe(rawUtx) - ).left.map(e => ApiError.BadRequest(e.getMessage)) - + Try(Hex.unsafe(rawUtx)).toEither.left.map(e => ApiError.BadRequest(e.getMessage)).flatMap { + rawBytes => + deserialize[protocol.model.UnsignedTransaction](rawBytes).left.map(e => + ApiError.BadRequest(e.getMessage) + ) + } } diff --git a/app/src/test/scala/org/alephium/explorer/web/TransactionServerSpec.scala b/app/src/test/scala/org/alephium/explorer/web/TransactionServerSpec.scala new file mode 100644 index 000000000..da2663b2f --- /dev/null +++ b/app/src/test/scala/org/alephium/explorer/web/TransactionServerSpec.scala @@ -0,0 +1,46 @@ +// Copyright (c) Alephium +// SPDX-License-Identifier: LGPL-3.0-only + +package org.alephium.explorer.web + +import scala.collection.immutable.ArraySeq + +import sttp.model.StatusCode + +import org.alephium.api.ApiError.NotFound +import org.alephium.explorer._ +import org.alephium.explorer.GenCoreProtocol._ +import org.alephium.explorer.HttpFixture._ +import org.alephium.explorer.api.model.Transaction +import org.alephium.explorer.persistence.DatabaseFixtureForAll + +class TransactionServerSpec() + extends AlephiumFutureSpec + with DatabaseFixtureForAll + with HttpServerFixture { + + private val server = new TransactionServer() + + override val routes = server.routes + + "transactions" should { + "return an empty list on an empty database" in { + Get("/transactions") check { response => + response.as[ArraySeq[Transaction]] is ArraySeq.empty + } + } + + "return not found for an unknown transaction id" in { + val txId = transactionHashGen.sample.get + Get(s"/transactions/${txId.value.toHexString}") check { response => + response.as[NotFound] is NotFound(txId.value.toHexString) + } + } + + "reject invalid unsigned transaction payloads" in { + Post("/transactions/decode-unsigned-tx", """{"unsignedTx":"not-hex"}""") check { response => + response.code is StatusCode.BadRequest + } + } + } +} From dddf8da0a507d06e08de09a4ab0c259d1e6dcc83 Mon Sep 17 00:00:00 2001 From: Thomas Droxler Date: Tue, 18 Aug 2026 09:51:21 +0200 Subject: [PATCH 3/5] Update scoverarge plugin --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 9c8bf899a..2fc51f608 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -9,6 +9,6 @@ addSbtPlugin("org.scalastyle" %% "scalastyle-sbt-plugin" % "1.0.0") addSbtPlugin("org.wartremover" % "sbt-wartremover" % "3.1.6") addSbtPlugin("se.marcuslonnberg" % "sbt-docker" % "1.9.0") addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") -addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.6") +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.3.0") addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.3") addSbtPlugin("com.github.sbt" % "sbt-unidoc" % "0.5.0") From 9a4fdb14ef672e5ba14987032056cbc50ca0fb4f Mon Sep 17 00:00:00 2001 From: Thomas Droxler Date: Tue, 18 Aug 2026 14:38:35 +0200 Subject: [PATCH 4/5] Remove unused index checker endpoint --- .../explorer/api/EndpointExamples.scala | 21 --------- .../explorer/api/UtilsEndpoints.scala | 7 --- .../persistence/model/InputEntity.scala | 7 +-- .../persistence/queries/BlockQueries.scala | 25 ---------- .../persistence/queries/ExplainResult.scala | 30 ------------ .../persistence/queries/InputQueries.scala | 23 --------- .../persistence/queries/OutputQueries.scala | 34 -------------- .../queries/result/TxByTokenQR.scala | 3 -- .../schema/CustomSetParameter.scala | 34 +------------- .../explorer/service/IndexChecker.scala | 47 ------------------- .../alephium/explorer/util/FutureUtil.scala | 10 ---- .../alephium/explorer/util/SlickUtil.scala | 19 -------- .../alephium/explorer/web/UtilsServer.scala | 5 +- .../explorer/util/SlickExplainUtil.scala | 28 ----------- 14 files changed, 3 insertions(+), 290 deletions(-) delete mode 100644 app/src/main/scala/org/alephium/explorer/persistence/queries/ExplainResult.scala delete mode 100644 app/src/main/scala/org/alephium/explorer/service/IndexChecker.scala rename app/src/{main => test}/scala/org/alephium/explorer/util/SlickExplainUtil.scala (61%) diff --git a/app/src/main/scala/org/alephium/explorer/api/EndpointExamples.scala b/app/src/main/scala/org/alephium/explorer/api/EndpointExamples.scala index 294d68d54..2dcfdd2bd 100644 --- a/app/src/main/scala/org/alephium/explorer/api/EndpointExamples.scala +++ b/app/src/main/scala/org/alephium/explorer/api/EndpointExamples.scala @@ -11,7 +11,6 @@ import sttp.tapir.EndpointIO.Example import org.alephium.api.EndpointsExamples import org.alephium.api.model.{Address => ApiAddress, Amount, ValBool} import org.alephium.explorer.api.model._ -import org.alephium.explorer.persistence.queries.ExplainResult import org.alephium.protocol.{ALPH, PublicKey} import org.alephium.protocol.mining.HashRate import org.alephium.protocol.model.{Address, BlockHash, ContractId, GroupIndex, TokenId} @@ -337,23 +336,6 @@ object EndpointExamples extends EndpointsExamples { value = 60 ) - private val explainResult = - ExplainResult( - queryName = "queryName", - queryInput = "Pagination(0,20,false)", - explain = Vector( - "Seq Scan on table_name (cost=0.00..850.88 rows=20088 width=198) (actual time=0.007..4.358 rows=20088 loops=1)", - " Filter: table_column", - "Planning Time: 0.694 ms", - "Execution Time: 5.432 ms" - ), - messages = Array( - "Used table_column_idx = false", - "Used table_pk = true" - ), - passed = true - ) - private val logbackValue = LogbackValue( name = "org.test", @@ -456,9 +438,6 @@ object EndpointExamples extends EndpointsExamples { implicit val perChainDurationExample: List[Example[ArraySeq[PerChainDuration]]] = simpleExample(ArraySeq(perChainDuration, perChainDuration)) - implicit val explainResultExample: List[Example[ArraySeq[ExplainResult]]] = - simpleExample(ArraySeq(explainResult)) - implicit val logbackValueExample: List[Example[ArraySeq[LogbackValue]]] = simpleExample(ArraySeq(logbackValue)) diff --git a/app/src/main/scala/org/alephium/explorer/api/UtilsEndpoints.scala b/app/src/main/scala/org/alephium/explorer/api/UtilsEndpoints.scala index 6dd86e375..dc7c7ffeb 100644 --- a/app/src/main/scala/org/alephium/explorer/api/UtilsEndpoints.scala +++ b/app/src/main/scala/org/alephium/explorer/api/UtilsEndpoints.scala @@ -11,7 +11,6 @@ import sttp.tapir.generic.auto._ import org.alephium.api.Endpoints.jsonBody import org.alephium.explorer.api.EndpointExamples._ import org.alephium.explorer.api.model.LogbackValue -import org.alephium.explorer.persistence.queries.ExplainResult // scalastyle:off magic.number trait UtilsEndpoints extends BaseEndpoint with QueryParams { @@ -29,12 +28,6 @@ trait UtilsEndpoints extends BaseEndpoint with QueryParams { .in("sanity-check") .summary("Perform a sanity check") - val indexCheck: BaseEndpoint[Unit, ArraySeq[ExplainResult]] = - utilsEndpoint.get - .in("index-check") - .out(jsonBody[ArraySeq[ExplainResult]]) - .summary("Perform index check") - val changeGlobalLogLevel: BaseEndpoint[String, Unit] = utilsEndpoint.put .in("update-global-loglevel") diff --git a/app/src/main/scala/org/alephium/explorer/persistence/model/InputEntity.scala b/app/src/main/scala/org/alephium/explorer/persistence/model/InputEntity.scala index e4805ff7a..72ccf7e52 100644 --- a/app/src/main/scala/org/alephium/explorer/persistence/model/InputEntity.scala +++ b/app/src/main/scala/org/alephium/explorer/persistence/model/InputEntity.scala @@ -52,9 +52,4 @@ final case class InputEntity( outputRefAmount: Option[U256], outputRefTokens: Option[ArraySeq[Token]], // None if empty list contractInput: Boolean -) extends InputEntityLike { - - /** @return All hash types associated with this [[InputEntity]] */ - def hashes(): (TransactionId, BlockHash) = - (txHash, blockHash) -} +) extends InputEntityLike diff --git a/app/src/main/scala/org/alephium/explorer/persistence/queries/BlockQueries.scala b/app/src/main/scala/org/alephium/explorer/persistence/queries/BlockQueries.scala index b49991bdb..a041c8aac 100644 --- a/app/src/main/scala/org/alephium/explorer/persistence/queries/BlockQueries.scala +++ b/app/src/main/scala/org/alephium/explorer/persistence/queries/BlockQueries.scala @@ -21,7 +21,6 @@ import org.alephium.explorer.persistence.queries.TransactionQueries._ import org.alephium.explorer.persistence.schema._ import org.alephium.explorer.persistence.schema.CustomGetResult._ import org.alephium.explorer.persistence.schema.CustomSetParameter._ -import org.alephium.explorer.util.SlickExplainUtil._ import org.alephium.explorer.util.SlickUtil._ import org.alephium.protocol.model.{BlockHash, GroupIndex, TransactionId} import org.alephium.util.TimeStamp @@ -66,17 +65,6 @@ object BlockQueries extends StrictLogging { @SuppressWarnings(Array("org.wartremover.warts.PublicInference")) val mainChainQuery = BlockHeaderSchema.table.filter(_.mainChain) - def explainMainChainQuery()(implicit ec: ExecutionContext): DBActionR[ExplainResult] = - mainChainQuery.result.explainAnalyze() map { explain => - ExplainResult( - queryName = "mainChainQuery", - queryInput = "Unit", - explain = explain, - messages = Iterable.empty, - passed = explain.mkString contains "block_headers_main_chain_idx" - ) - } - def getBlockEntryLiteAction( hash: BlockHash ): DBActionR[Option[BlockEntryLite]] = @@ -210,19 +198,6 @@ object BlockQueries extends StrictLogging { listMainChainHeadersWithTxnNumberBuilder(pagination) .asASE[BlockEntryLite](blockEntryListGetResult) - def explainListMainChainHeadersWithTxnNumber( - pagination: Pagination.Reversible - )(implicit ec: ExecutionContext): DBActionR[ExplainResult] = - listMainChainHeadersWithTxnNumberBuilder(pagination).explainAnalyze() map { explain => - ExplainResult( - queryName = "listMainChainHeadersWithTxnNumber", - queryInput = pagination.toString, - explain = explain, - messages = Iterable.empty, - passed = explain.mkString contains "block_headers_full_index" - ) - } - def listMainChainHeadersWithTxnNumberBuilder( pagination: Pagination.Reversible ): SQLActionBuilder = { diff --git a/app/src/main/scala/org/alephium/explorer/persistence/queries/ExplainResult.scala b/app/src/main/scala/org/alephium/explorer/persistence/queries/ExplainResult.scala deleted file mode 100644 index 87fa0bd60..000000000 --- a/app/src/main/scala/org/alephium/explorer/persistence/queries/ExplainResult.scala +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Alephium -// SPDX-License-Identifier: LGPL-3.0-only - -package org.alephium.explorer.persistence.queries - -import org.alephium.json.Json._ - -object ExplainResult { - implicit val readWriter: ReadWriter[ExplainResult] = macroRW - - /** Indicates an empty input. Explain cannot be executed on parametric queries with no parameters - */ - def emptyInput(queryName: String): ExplainResult = - new ExplainResult( - queryName = queryName, - queryInput = "empty", - explain = Vector.empty, - messages = Vector("Empty input"), - passed = false - ) - -} - -final case class ExplainResult( - queryName: String, - queryInput: String, - explain: Vector[String], - messages: Iterable[String], - passed: Boolean -) diff --git a/app/src/main/scala/org/alephium/explorer/persistence/queries/InputQueries.scala b/app/src/main/scala/org/alephium/explorer/persistence/queries/InputQueries.scala index 1571eed20..130319963 100644 --- a/app/src/main/scala/org/alephium/explorer/persistence/queries/InputQueries.scala +++ b/app/src/main/scala/org/alephium/explorer/persistence/queries/InputQueries.scala @@ -17,7 +17,6 @@ import org.alephium.explorer.persistence.model._ import org.alephium.explorer.persistence.queries.result.{InputFromTxQR, InputQR} import org.alephium.explorer.persistence.schema.CustomGetResult._ import org.alephium.explorer.persistence.schema.CustomSetParameter._ -import org.alephium.explorer.util.SlickExplainUtil._ import org.alephium.explorer.util.SlickUtil._ import org.alephium.protocol.model.{BlockHash, TransactionId} @@ -150,26 +149,4 @@ object InputQueries { LIMIT 1 """.asAS[ByteString].headOrNone } - - /** Runs explain on query `inputsFromTxs` and checks the index `inputs_tx_hash_block_hash_idx` is - * being used - */ - def explainInputsFromTxs( - hashes: ArraySeq[(TransactionId, BlockHash)] - )(implicit ec: ExecutionContext): DBActionR[ExplainResult] = { - val queryName = "inputsFromTxs" - if (hashes.isEmpty) { - DBIOAction.successful(ExplainResult.emptyInput(queryName)) - } else { - inputsFromTxsBuilder(hashes).explainAnalyze() map { explain => - ExplainResult( - queryName = queryName, - queryInput = hashes.toString(), - explain = explain, - messages = Iterable.empty, - passed = explain.exists(_.contains("inputs_tx_hash_block_hash_idx")) - ) - } - } - } } diff --git a/app/src/main/scala/org/alephium/explorer/persistence/queries/OutputQueries.scala b/app/src/main/scala/org/alephium/explorer/persistence/queries/OutputQueries.scala index bb1b375b4..3a12b2af7 100644 --- a/app/src/main/scala/org/alephium/explorer/persistence/queries/OutputQueries.scala +++ b/app/src/main/scala/org/alephium/explorer/persistence/queries/OutputQueries.scala @@ -17,7 +17,6 @@ import org.alephium.explorer.persistence.model._ import org.alephium.explorer.persistence.queries.result.{OutputFromTxQR, OutputQR} import org.alephium.explorer.persistence.schema.CustomGetResult._ import org.alephium.explorer.persistence.schema.CustomSetParameter._ -import org.alephium.explorer.util.SlickExplainUtil._ import org.alephium.explorer.util.SlickUtil._ import org.alephium.protocol.Hash import org.alephium.protocol.model.{BlockHash, TransactionId} @@ -390,39 +389,6 @@ object OutputQueries { """.asASE[OutputEntity](outputGetResult) } - /** Checks that [[getTxnHash]] uses both indexes for the given key */ - def explainGetTxnHash( - key: Option[Hash] - )(implicit ec: ExecutionContext): DBActionR[ExplainResult] = { - val queryName = "getTxnHashBuilder" - - key match { - case Some(key) => - getTxnHashBuilder(key).explainAnalyze() map { explain => - val explainString = explain.mkString - val outputs_pk_used = explainString contains "outputs_pk" - val outputs_main_chain_idx_used = explainString contains "outputs_main_chain_idx" - val passed = outputs_pk_used && outputs_main_chain_idx_used - val message = - ArraySeq( - s"Used outputs_main_chain_idx = $outputs_main_chain_idx_used", - s"Used outputs_pk = $outputs_pk_used" - ) - - ExplainResult( - queryName = queryName, - queryInput = key.toString(), - explain = explain, - messages = message, - passed = passed - ) - } - - case None => - DBIOAction.successful(ExplainResult.emptyInput(queryName)) - } - } - def getTxnHash(key: Hash): DBActionSR[TransactionId] = getTxnHashBuilder(key).asAS[TransactionId] diff --git a/app/src/main/scala/org/alephium/explorer/persistence/queries/result/TxByTokenQR.scala b/app/src/main/scala/org/alephium/explorer/persistence/queries/result/TxByTokenQR.scala index bd9c0dae1..05658cdd6 100644 --- a/app/src/main/scala/org/alephium/explorer/persistence/queries/result/TxByTokenQR.scala +++ b/app/src/main/scala/org/alephium/explorer/persistence/queries/result/TxByTokenQR.scala @@ -55,9 +55,6 @@ final case class TxByTokenQR( conflicted: Option[Boolean] ) { - def hashes(): (TransactionId, BlockHash) = - (txHash, blockHash) - def toTxByAddressQR: TxByAddressQR = TxByAddressQR( txHash, blockHash, diff --git a/app/src/main/scala/org/alephium/explorer/persistence/schema/CustomSetParameter.scala b/app/src/main/scala/org/alephium/explorer/persistence/schema/CustomSetParameter.scala index 8437cec6b..d321a07dd 100644 --- a/app/src/main/scala/org/alephium/explorer/persistence/schema/CustomSetParameter.scala +++ b/app/src/main/scala/org/alephium/explorer/persistence/schema/CustomSetParameter.scala @@ -14,7 +14,7 @@ import org.alephium.api.model.{Address => ApiAddress} import org.alephium.api.model.Val import org.alephium.explorer.api.Json._ import org.alephium.explorer.api.model._ -import org.alephium.explorer.persistence.model.{GrouplessAddress, InterfaceIdEntity, OutputEntity} +import org.alephium.explorer.persistence.model.{GrouplessAddress, OutputEntity} import org.alephium.json.Json._ import org.alephium.protocol.Hash import org.alephium.protocol.model._ @@ -41,17 +41,6 @@ object CustomSetParameter { params setInt input.value } - implicit object GroupIndexOptionSetParameter extends SetParameter[Option[GroupIndex]] { - override def apply(option: Option[GroupIndex], params: PositionedParameters): Unit = - option match { - case Some(group) => - GroupIndexSetParameter(group, params) - - case None => - params setIntOption None - } - } - implicit object IntervalTypeSetParameter extends SetParameter[IntervalType] { override def apply(input: IntervalType, params: PositionedParameters): Unit = params setInt input.value @@ -62,11 +51,6 @@ object CustomSetParameter { params setString input.id } - implicit object InterfaceIdSetParameter extends SetParameter[InterfaceIdEntity] { - override def apply(input: InterfaceIdEntity, params: PositionedParameters): Unit = - params setString input.id - } - implicit object OutputTypeSetParameter extends SetParameter[OutputEntity.OutputType] { override def apply(input: OutputEntity.OutputType, params: PositionedParameters): Unit = params setInt input.value @@ -120,17 +104,6 @@ object CustomSetParameter { } } - implicit object OptionApiAddressSetParameter extends SetParameter[Option[ApiAddress]] { - override def apply(option: Option[ApiAddress], params: PositionedParameters): Unit = - option match { - case Some(address) => - ApiAddressSetParameter(address, params) - - case None => - params setStringOption None - } - } - implicit object OptionGrouplessAddressSetParameter extends SetParameter[Option[GrouplessAddress]] { override def apply(option: Option[GrouplessAddress], params: PositionedParameters): Unit = @@ -143,11 +116,6 @@ object CustomSetParameter { } } - implicit object ArrayByteStringSetParameter extends SetParameter[Array[Byte]] { - override def apply(input: Array[Byte], params: PositionedParameters): Unit = - params setBytes input - } - implicit object ByteStringSetParameter extends SetParameter[ByteString] { override def apply(input: ByteString, params: PositionedParameters): Unit = params setBytes input.toArray diff --git a/app/src/main/scala/org/alephium/explorer/service/IndexChecker.scala b/app/src/main/scala/org/alephium/explorer/service/IndexChecker.scala deleted file mode 100644 index 23c33034f..000000000 --- a/app/src/main/scala/org/alephium/explorer/service/IndexChecker.scala +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Alephium -// SPDX-License-Identifier: LGPL-3.0-only - -package org.alephium.explorer.service - -import scala.collection.immutable.ArraySeq -import scala.concurrent.{ExecutionContext, Future} - -import slick.basic.DatabaseConfig -import slick.jdbc.PostgresProfile - -import org.alephium.explorer.api.model.Pagination -import org.alephium.explorer.persistence.DBActionR -import org.alephium.explorer.persistence.DBRunner._ -import org.alephium.explorer.persistence.queries._ -import org.alephium.explorer.util.SlickUtil._ - -// scalastyle:off magic.number -object IndexChecker { - - /** Run query checks */ - def check()(implicit - ec: ExecutionContext, - dc: DatabaseConfig[PostgresProfile] - ): Future[ArraySeq[ExplainResult]] = - run(checkAction()) - - def checkAction()(implicit ec: ExecutionContext): DBActionR[ArraySeq[ExplainResult]] = - for { - a <- BlockQueries.explainListMainChainHeadersWithTxnNumber( - Pagination.Reversible.unsafe(1, 20) - ) // first page - b <- BlockQueries.explainListMainChainHeadersWithTxnNumber( - Pagination.Reversible.unsafe(10000, 20) - ) // far page - c <- BlockQueries.explainMainChainQuery() - oldestOutputEntity <- OutputQueries.getMainChainOutputs(true).headOrEmpty - latestOutputEntity <- OutputQueries.getMainChainOutputs(false).headOrEmpty - d <- OutputQueries.explainGetTxnHash(oldestOutputEntity.map(_.key).headOption) - e <- OutputQueries.explainGetTxnHash(latestOutputEntity.map(_.key).headOption) - oldestInputEntity <- InputQueries.getMainChainInputs(true).headOrEmpty - latestInputEntity <- InputQueries.getMainChainInputs(false).headOrEmpty - f <- InputQueries.explainInputsFromTxs(oldestInputEntity.map(_.hashes())) - g <- InputQueries.explainInputsFromTxs(latestInputEntity.map(_.hashes())) - } yield ArraySeq(a, b, c, d, e, f, g).sortBy(_.passed) - -} diff --git a/app/src/main/scala/org/alephium/explorer/util/FutureUtil.scala b/app/src/main/scala/org/alephium/explorer/util/FutureUtil.scala index 7b51b996c..28e9568c1 100644 --- a/app/src/main/scala/org/alephium/explorer/util/FutureUtil.scala +++ b/app/src/main/scala/org/alephium/explorer/util/FutureUtil.scala @@ -11,16 +11,6 @@ object FutureUtil extends StrictLogging { implicit class FutureEnrichment[A](val future: Future[A]) extends AnyVal { - /** Maps to function for the Future in this/current Thread - * - * @note - * DO NOT use to execute code that does not return immediately. - * @see - * [[scala.concurrent.ExecutionContext.parasitic]] for details. - */ - @inline def mapSync[B](f: A => B): Future[B] = - future.map(f)(ExecutionContext.parasitic) - /** Maps to the input value for the Future in this/current Thread * * @note diff --git a/app/src/main/scala/org/alephium/explorer/util/SlickUtil.scala b/app/src/main/scala/org/alephium/explorer/util/SlickUtil.scala index 454878a3a..3e9893f3c 100644 --- a/app/src/main/scala/org/alephium/explorer/util/SlickUtil.scala +++ b/app/src/main/scala/org/alephium/explorer/util/SlickUtil.scala @@ -55,25 +55,6 @@ object SlickUtil { } } - implicit class OptionResultEnrichment[A](val action: DBActionSR[Option[A]]) extends AnyVal { - - /** Expects query to return less than or equal to 1. Fails is the result is greater than 1. - * - * This is used to ensure queries selecting on SQL functions like `sum` and `max` return only - * one row or none. If the more than one rows are return then there is a problem in the query - * itself. - */ - @SuppressWarnings(Array("org.wartremover.warts.IterableOps")) - def oneOrNone(implicit ec: ExecutionContext): DBActionR[Option[A]] = - action.flatMap { rows => - rows.size match { - case 0 => DBIO.successful(None) - case 1 => DBIO.successful(rows.head) - case n => DBIO.failed(new RuntimeException(s"Expected 1 result, actual $n")) - } - } - } - @SuppressWarnings(Array("org.wartremover.warts.Overloading")) implicit class RichSqlActionBuilder[A](val action: SQLActionBuilder) extends AnyVal { @SuppressWarnings( diff --git a/app/src/main/scala/org/alephium/explorer/web/UtilsServer.scala b/app/src/main/scala/org/alephium/explorer/web/UtilsServer.scala index 958bc3f21..418ad3d45 100644 --- a/app/src/main/scala/org/alephium/explorer/web/UtilsServer.scala +++ b/app/src/main/scala/org/alephium/explorer/web/UtilsServer.scala @@ -19,7 +19,7 @@ import org.alephium.explorer.GroupSetting import org.alephium.explorer.api.UtilsEndpoints import org.alephium.explorer.api.model.LogbackValue import org.alephium.explorer.cache.BlockCache -import org.alephium.explorer.service.{BlockFlowClient, IndexChecker, SanityChecker} +import org.alephium.explorer.service.{BlockFlowClient, SanityChecker} import org.alephium.util.discard class UtilsServer()(implicit @@ -37,9 +37,6 @@ class UtilsServer()(implicit discard(SanityChecker.check()) Future.successful(()) }), - route(indexCheck.serverLogic[Future] { _ => - IndexChecker.check().map(Right(_)) - }), route(changeGlobalLogLevel.serverLogic[Future] { level => Future.successful(updateGlobalLevel(level)) }), diff --git a/app/src/main/scala/org/alephium/explorer/util/SlickExplainUtil.scala b/app/src/test/scala/org/alephium/explorer/util/SlickExplainUtil.scala similarity index 61% rename from app/src/main/scala/org/alephium/explorer/util/SlickExplainUtil.scala rename to app/src/test/scala/org/alephium/explorer/util/SlickExplainUtil.scala index 0a401ae92..58e5ab44e 100644 --- a/app/src/main/scala/org/alephium/explorer/util/SlickExplainUtil.scala +++ b/app/src/test/scala/org/alephium/explorer/util/SlickExplainUtil.scala @@ -2,47 +2,19 @@ // SPDX-License-Identifier: LGPL-3.0-only package org.alephium.explorer.util -import scala.concurrent.ExecutionContext - import slick.dbio.Effect import slick.jdbc.PostgresProfile.api._ import slick.jdbc.SQLActionBuilder import slick.sql.{FixedSqlStreamingAction, SqlStreamingAction} -import org.alephium.explorer.persistence.DBActionR - object SlickExplainUtil { /** For SQL queries */ implicit class SQLActionBuilderImplicits(sql: SQLActionBuilder) { - /** Adds `EXPLAIN ANALYZE` to head query */ - def explainAnalyze(): SqlStreamingAction[Vector[String], String, Effect.Read] = - alterHeadQuery(sql, "EXPLAIN ANALYZE") - - /** Adds `EXPLAIN` to head query */ - def explain(): SqlStreamingAction[Vector[String], String, Effect.Read] = - alterHeadQuery(sql, "EXPLAIN") - } - - /** For typed static queries */ - implicit class FixedSqlStreamingActionImplicits[+R, +T, -E <: Effect]( - sql: FixedSqlStreamingAction[R, T, E] - ) { - - /** Adds `EXPLAIN ANALYZE` to head query */ - def explainAnalyze(): SqlStreamingAction[Vector[String], String, Effect.Read] = - alterHeadQuery(sql, "EXPLAIN ANALYZE") - - def explainAnalyzeFlatten()(implicit ec: ExecutionContext): DBActionR[String] = - explainAnalyze().map(_.mkString("\n")) - /** Adds `EXPLAIN` to head query */ def explain(): SqlStreamingAction[Vector[String], String, Effect.Read] = alterHeadQuery(sql, "EXPLAIN") - - def explainFlatten()(implicit ec: ExecutionContext): DBActionR[String] = - explain().map(_.mkString("\n")) } /** Alter's first query with the prefix. */ From cc6518a19786b78bc83dc4b41bbf6c80ceecdad2 Mon Sep 17 00:00:00 2001 From: Thomas Droxler Date: Tue, 18 Aug 2026 14:54:35 +0200 Subject: [PATCH 5/5] Add some obvious missing tests --- .../org/alephium/explorer/GenApiModel.scala | 6 + .../org/alephium/explorer/MainSpec.scala | 87 +++++++++++ .../explorer/api/model/ApiModelSpec.scala | 8 + .../cache/CaffeineAsyncCacheSpec.scala | 103 +++++++++++-- .../explorer/config/BootModeSpec.scala | 22 +++ .../explorer/config/ExplorerConfigSpec.scala | 35 +++++ .../explorer/persistence/MigrationsSpec.scala | 66 +++++++++ .../persistence/queries/InfoQueriesSpec.scala | 65 ++++++++ .../queries/TokenQueriesSpec.scala | 47 +++++- .../explorer/service/SanityCheckerSpec.scala | 115 ++++++++++++++ .../explorer/web/InfosServerSpec.scala | 140 ++++++++++++++---- .../explorer/web/MetricsServerSpec.scala | 45 ++++++ .../explorer/web/TokenServerSpec.scala | 111 +++++++++++++- 13 files changed, 806 insertions(+), 44 deletions(-) create mode 100644 app/src/test/scala/org/alephium/explorer/MainSpec.scala create mode 100644 app/src/test/scala/org/alephium/explorer/persistence/MigrationsSpec.scala create mode 100644 app/src/test/scala/org/alephium/explorer/persistence/queries/InfoQueriesSpec.scala create mode 100644 app/src/test/scala/org/alephium/explorer/service/SanityCheckerSpec.scala create mode 100644 app/src/test/scala/org/alephium/explorer/web/MetricsServerSpec.scala diff --git a/app/src/test/scala/org/alephium/explorer/GenApiModel.scala b/app/src/test/scala/org/alephium/explorer/GenApiModel.scala index 4d83fce52..784733bac 100644 --- a/app/src/test/scala/org/alephium/explorer/GenApiModel.scala +++ b/app/src/test/scala/org/alephium/explorer/GenApiModel.scala @@ -424,6 +424,12 @@ object GenApiModel extends ImplicitConversions { balance <- amountGen } yield HolderInfo(address, balance) + def timedAmountGen: Gen[TimedAmount] = + for { + timestamp <- timestampGen + amount <- amountGen + } yield TimedAmount(timestamp, amount.v) + def ghostUncleGen()(implicit groupSetting: GroupSetting): Gen[GhostUncle] = for { blockHash <- blockHashGen miner <- addressAssetProtocolGen() diff --git a/app/src/test/scala/org/alephium/explorer/MainSpec.scala b/app/src/test/scala/org/alephium/explorer/MainSpec.scala new file mode 100644 index 000000000..fad19d5b3 --- /dev/null +++ b/app/src/test/scala/org/alephium/explorer/MainSpec.scala @@ -0,0 +1,87 @@ +// Copyright (c) Alephium +// SPDX-License-Identifier: LGPL-3.0-only + +package org.alephium.explorer + +import java.nio.charset.StandardCharsets +import java.nio.file.Files + +import org.alephium.explorer.config.{BootMode, ExplorerConfig, Platform} + +class MainSpec extends AlephiumSpec { + + private def withUserHome(path: String)(body: => Unit): Unit = { + val previous = sys.props.get("user.home") + + try { + System.setProperty("user.home", path) + () + body + } finally { + previous match { + case Some(value) => + System.setProperty("user.home", value) + () + case None => + System.clearProperty("user.home") + () + } + } + } + + private def withUserConfig(content: Option[String])(body: => Unit): Unit = { + val rootPath = Platform.getRootPath() + val userFile = ExplorerConfig.getUserConfig(rootPath) + val previous = + if (userFile.exists()) Some(Files.readString(userFile.toPath)) else None + + def restore(): Unit = { + previous match { + case Some(value) => + Files.writeString(userFile.toPath, value, StandardCharsets.UTF_8) + () + case None => + Files.deleteIfExists(userFile.toPath) + () + } + } + + try { + content match { + case Some(value) => + Files.writeString(userFile.toPath, value, StandardCharsets.UTF_8) + () + case None => + Files.deleteIfExists(userFile.toPath) + () + } + body + } finally { + restore() + } + } + + "BootUp" should { + "load the default config from the root path" in { + val homePath = Files.createTempDirectory("explorer-home") + withUserHome(homePath.toString) { + withUserConfig(None) { + val bootUp = new BootUp + bootUp.config.bootMode is BootMode.ReadWrite + () + } + } + } + } + + "Main.main" should { + "swallow boot errors and return" in { + val homePath = Files.createTempDirectory("explorer-home") + withUserHome(homePath.toString) { + withUserConfig(Some("not-valid-hocon")) { + Main.main(Array.empty) + } + } + } + } +} diff --git a/app/src/test/scala/org/alephium/explorer/api/model/ApiModelSpec.scala b/app/src/test/scala/org/alephium/explorer/api/model/ApiModelSpec.scala index 717317f5a..58df801ff 100644 --- a/app/src/test/scala/org/alephium/explorer/api/model/ApiModelSpec.scala +++ b/app/src/test/scala/org/alephium/explorer/api/model/ApiModelSpec.scala @@ -452,4 +452,12 @@ class ApiModelSpec() extends AlephiumSpec { grouped.toBase58 is address + s":$i" } } + + "TimedAmount" in { + forAll(timedAmountGen) { timedAmount => + val expected = s"""[${timedAmount.timestamp.millis},"${timedAmount.amount}"]""" + + check(timedAmount, expected) + } + } } diff --git a/app/src/test/scala/org/alephium/explorer/cache/CaffeineAsyncCacheSpec.scala b/app/src/test/scala/org/alephium/explorer/cache/CaffeineAsyncCacheSpec.scala index c4b67a4c1..5f7417194 100644 --- a/app/src/test/scala/org/alephium/explorer/cache/CaffeineAsyncCacheSpec.scala +++ b/app/src/test/scala/org/alephium/explorer/cache/CaffeineAsyncCacheSpec.scala @@ -4,27 +4,61 @@ package org.alephium.explorer.cache import java.util.concurrent.{CompletableFuture, Executor, TimeUnit} +import java.util.concurrent.atomic.AtomicInteger + +import scala.collection.immutable.ArraySeq +import scala.concurrent.Future +import scala.jdk.CollectionConverters.* import com.github.benmanes.caffeine.cache.{AsyncCacheLoader, Caffeine} +import slick.jdbc.PostgresProfile.api._ import org.alephium.explorer.AlephiumFutureSpec +import org.alephium.explorer.persistence.{DBAction, DBRunner} +import org.alephium.explorer.persistence.schema.BlockHeaderSchema class CaffeineAsyncCacheSpec extends AlephiumFutureSpec { - "return None for getIfPresent when cache is empty (NullPointerException check)" in { - val cache = - CaffeineAsyncCache { - Caffeine - .newBuilder() - .expireAfterWrite(10, TimeUnit.MINUTES) - .maximumSize(10) - .buildAsync[Int, String] { - new AsyncCacheLoader[Int, String] { - override def asyncLoad(key: Int, executor: Executor): CompletableFuture[String] = - fail("Async load is not required for this test") + @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) + private def unsafeCast[R](value: Int): R = + value.asInstanceOf[R] + + @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf")) + private def rowCountQuery(query: Query[_, _, Seq]): Query[_, _, ArraySeq] = + query.asInstanceOf[Query[_, _, ArraySeq]] + + private def newCache( + loaderCount: AtomicInteger + ): CaffeineAsyncCache[Int, String] = + CaffeineAsyncCache { + Caffeine + .newBuilder() + .expireAfterWrite(10, TimeUnit.MINUTES) + .maximumSize(10) + .buildAsync[Int, String] { + new AsyncCacheLoader[Int, String] { + override def asyncLoad(key: Int, executor: Executor): CompletableFuture[String] = { + loaderCount.incrementAndGet() + CompletableFuture.completedFuture(s"value-$key") + } + + override def asyncLoadAll( + keys: java.util.Set[_ <: Int], + _executor: Executor + ): CompletableFuture[java.util.Map[_ <: Int, _ <: String]] = { + val result = new java.util.HashMap[Int, String]() + keys.iterator().asScala.foreach { key => + loaderCount.incrementAndGet() + result.put(key, s"value-$key") + } + CompletableFuture.completedFuture(result) } } - } + } + } + + "return None for getIfPresent when cache is empty (NullPointerException check)" in { + val cache = newCache(new AtomicInteger(0)) // does not throw NullPointerException cache.getIfPresent(1) is None @@ -35,4 +69,49 @@ class CaffeineAsyncCacheSpec extends AlephiumFutureSpec { } + "load values with get and getAll" in { + val loaderCount = new AtomicInteger(0) + val cache = newCache(loaderCount) + + cache.get(1).futureValue is "value-1" + loaderCount.get() is 1 + + cache.getAll(List(1, 2)).futureValue.toSet is Set(1 -> "value-1", 2 -> "value-2") + loaderCount.get() is 2 + } + + "invalidate cached values" in { + val cache = newCache(new AtomicInteger(0)) + + cache.put(1, "one") + cache.put(2, "two") + + cache.invalidate(1) + cache.getIfPresent(1) is None + cache.getIfPresent(2).map(_.futureValue) is Some("two") + + cache.invalidateAll() + cache.getIfPresent(1) is None + cache.getIfPresent(2) is None + } + + "load row counts with rowCountCache" in { + val calls = new AtomicInteger(0) + val runner = new DBRunner { + override def databaseConfig: slick.basic.DatabaseConfig[slick.jdbc.PostgresProfile] = + throw new UnsupportedOperationException("unused") + + override def run[R, E <: Effect](action: DBAction[R, E]): Future[R] = { + calls.incrementAndGet() + Future.successful(unsafeCast[R](42)) + } + } + + val cache = + CaffeineAsyncCache.rowCountCache(runner)(Caffeine.newBuilder().maximumSize(10)) + + cache.get(rowCountQuery(BlockHeaderSchema.table)).futureValue is 42 + calls.get() is 1 + } + } diff --git a/app/src/test/scala/org/alephium/explorer/config/BootModeSpec.scala b/app/src/test/scala/org/alephium/explorer/config/BootModeSpec.scala index 26dbcc2bc..409dd8467 100644 --- a/app/src/test/scala/org/alephium/explorer/config/BootModeSpec.scala +++ b/app/src/test/scala/org/alephium/explorer/config/BootModeSpec.scala @@ -29,4 +29,26 @@ class BootModeSpec extends AlephiumSpec with ScalaCheckDrivenPropertyChecks { } } } + + "helpers" should { + "resolve modes" in { + BootMode("ReadOnly") is Some(BootMode.ReadOnly) + BootMode("ReadWrite") is Some(BootMode.ReadWrite) + BootMode("WriteOnly") is Some(BootMode.WriteOnly) + BootMode.all.foreach { mode => + BootMode(mode.productPrefix) is Some(mode) + } + BootMode("Other") is None + } + + "classify modes" in { + BootMode.readable(BootMode.ReadOnly) is true + BootMode.readable(BootMode.ReadWrite) is true + BootMode.readable(BootMode.WriteOnly) is false + + BootMode.writable(BootMode.ReadOnly) is false + BootMode.writable(BootMode.ReadWrite) is true + BootMode.writable(BootMode.WriteOnly) is true + } + } } diff --git a/app/src/test/scala/org/alephium/explorer/config/ExplorerConfigSpec.scala b/app/src/test/scala/org/alephium/explorer/config/ExplorerConfigSpec.scala index 1641ed928..f383988b8 100644 --- a/app/src/test/scala/org/alephium/explorer/config/ExplorerConfigSpec.scala +++ b/app/src/test/scala/org/alephium/explorer/config/ExplorerConfigSpec.scala @@ -3,6 +3,8 @@ package org.alephium.explorer.config +import java.nio.file.Files + import scala.collection.immutable.ArraySeq import scala.concurrent.duration._ @@ -80,6 +82,30 @@ class ExplorerConfigSpec extends AlephiumSpec with ScalaCheckDrivenPropertyCheck } } } + + "load config from user file" in { + val rootPath = Files.createTempDirectory("explorer-config-spec") + val userFile = getUserConfig(rootPath) + val content = + """ + |alephium { + | blockflow { + | network-id = 2 + | } + |} + |""".stripMargin + java.nio.file.Files.writeString(userFile.toPath, content) + + val config = loadConfig(rootPath).success.value + config.getInt("alephium.blockflow.network-id") is 2 + } + + "load config with the default network when the user file is empty" in { + val rootPath = Files.createTempDirectory("explorer-config-spec-default") + + val config = loadConfig(rootPath).success.value + config.getInt("alephium.blockflow.network-id") is NetworkId.AlephiumMainNet.id.toInt + } } "validatePort" should { @@ -148,6 +174,15 @@ class ExplorerConfigSpec extends AlephiumSpec with ScalaCheckDrivenPropertyCheck } } + "validateUri" should { + "build a uri" in { + forAll(Gen.oneOf("http", "https"), genPortNum) { (scheme, port) => + validateUri(scheme, "localhost", port).success.value.toString is + s"$scheme://localhost:$port" + } + } + } + "validateNetworkId" should { "fail validation" when { "networkId is greater than Byte.MaxValue" in { diff --git a/app/src/test/scala/org/alephium/explorer/persistence/MigrationsSpec.scala b/app/src/test/scala/org/alephium/explorer/persistence/MigrationsSpec.scala new file mode 100644 index 000000000..aa4260403 --- /dev/null +++ b/app/src/test/scala/org/alephium/explorer/persistence/MigrationsSpec.scala @@ -0,0 +1,66 @@ +// Copyright (c) Alephium +// SPDX-License-Identifier: LGPL-3.0-only + +package org.alephium.explorer.persistence + +import org.alephium.explorer.AlephiumFutureSpec +import org.alephium.explorer.config.{ExplorerConfig, TestExplorerConfig} +import org.alephium.explorer.persistence.model.AppState.MigrationVersion +import org.alephium.util.{Duration, TimeStamp} + +class MigrationsSpec extends AlephiumFutureSpec with DatabaseFixtureForEach with TestDBRunner { + + private def configWithForkTimestamp(forkTimestamp: TimeStamp): ExplorerConfig = { + val config = TestExplorerConfig() + config.copy( + consensus = config.consensus.copy( + danube = config.consensus.danube.copy(forkTimestamp = forkTimestamp) + ) + ) + } + + "migrationsQuery" should { + "do nothing when the version is unknown" in { + exec(Migrations.migrationsQuery(None)) is () + } + + "do nothing for the latest version" in { + exec(Migrations.migrationsQuery(Some(MigrationVersion(Migrations.latestVersion.version)))) is + () + } + + "reject future versions" in { + intercept[Exception] { + Migrations.migrationsQuery( + Some( + MigrationVersion(Migrations.latestVersion.version + 1) + ) + ) + }.getMessage should include("Incompatible migration versions") + } + + "apply all migrations from the initial version" in { + exec(Migrations.migrationsQuery(Some(MigrationVersion(0)))) is () + } + } + + "migration6" should { + "skip groupless address migration before the fork" in { + exec( + Migrations.migration6( + configWithForkTimestamp(TimeStamp.now().plusHoursUnsafe(24)), + executionContext + ) + ) is () + } + + "run groupless address migration after the fork" in { + exec( + Migrations.migration6( + configWithForkTimestamp(TimeStamp.now().minusUnsafe(Duration.ofHoursUnsafe(24))), + executionContext + ) + ) is () + } + } +} diff --git a/app/src/test/scala/org/alephium/explorer/persistence/queries/InfoQueriesSpec.scala b/app/src/test/scala/org/alephium/explorer/persistence/queries/InfoQueriesSpec.scala new file mode 100644 index 000000000..edee2a7d0 --- /dev/null +++ b/app/src/test/scala/org/alephium/explorer/persistence/queries/InfoQueriesSpec.scala @@ -0,0 +1,65 @@ +// Copyright (c) Alephium +// SPDX-License-Identifier: LGPL-3.0-only + +package org.alephium.explorer.persistence.queries + +import org.scalacheck.Gen +import slick.jdbc.PostgresProfile.api._ + +import org.alephium.explorer.AlephiumFutureSpec +import org.alephium.explorer.ConfigDefaults._ +import org.alephium.explorer.GenApiModel._ +import org.alephium.explorer.api.model.Pagination +import org.alephium.explorer.persistence.{DatabaseFixtureForEach, TestDBRunner} +import org.alephium.explorer.persistence.model.{HolderEntity, TokenHolderEntity} +import org.alephium.explorer.persistence.schema.{AlphHolderSchema, TokenHolderSchema} +import org.alephium.util.U256 + +class InfoQueriesSpec extends AlephiumFutureSpec with DatabaseFixtureForEach with TestDBRunner { + + "getAlphHoldersAction" should { + "return holders ordered by balance descending" in { + val addresses = Gen.listOfN(3, addressGen).sample.get + val balances = List(U256.unsafe(10), U256.unsafe(30), U256.unsafe(20)) + val holders = addresses.zip(balances).map { case (address, balance) => + HolderEntity(address, balance) + } + + exec(AlphHolderSchema.table.delete) + exec(AlphHolderSchema.table ++= holders) + + val result = exec(InfoQueries.getAlphHoldersAction(Pagination.unsafe(1, 2))) + + result is holders + .sortBy(_.balance)(Ordering[U256].reverse) + .take(2) + .map(h => (h.address, h.balance)) + } + } + + "getTokenHoldersAction" should { + "filter by token and return holders ordered by balance descending" in { + val token = tokenIdGen.sample.get + val otherToken = tokenIdGen.sample.get + val firstAddress = addressGen.sample.get + val secondAddress = addressGen.sample.get + val thirdAddress = addressGen.sample.get + + val holders = Seq( + TokenHolderEntity(firstAddress, token, U256.unsafe(5)), + TokenHolderEntity(secondAddress, token, U256.unsafe(15)), + TokenHolderEntity(thirdAddress, otherToken, U256.unsafe(100)) + ) + + exec(TokenHolderSchema.table.delete) + exec(TokenHolderSchema.table ++= holders) + + val result = exec(InfoQueries.getTokenHoldersAction(token, Pagination.unsafe(1, 10))) + + result is Seq( + (secondAddress, U256.unsafe(15)), + (firstAddress, U256.unsafe(5)) + ) + } + } +} diff --git a/app/src/test/scala/org/alephium/explorer/persistence/queries/TokenQueriesSpec.scala b/app/src/test/scala/org/alephium/explorer/persistence/queries/TokenQueriesSpec.scala index 29a3309d7..0bcf043c4 100644 --- a/app/src/test/scala/org/alephium/explorer/persistence/queries/TokenQueriesSpec.scala +++ b/app/src/test/scala/org/alephium/explorer/persistence/queries/TokenQueriesSpec.scala @@ -9,21 +9,61 @@ import org.scalacheck.Gen import slick.dbio.DBIOAction import slick.jdbc.PostgresProfile.api._ -import org.alephium.explorer.{AlephiumFutureSpec, GroupSetting} +import org.alephium.explorer.AlephiumFutureSpec import org.alephium.explorer.ConfigDefaults._ import org.alephium.explorer.GenApiModel._ import org.alephium.explorer.GenDBModel._ -import org.alephium.explorer.api.model.Pagination +import org.alephium.explorer.api.model.{Pagination, StdInterfaceId} import org.alephium.explorer.persistence.{DatabaseFixtureForEach, TestDBRunner} +import org.alephium.explorer.persistence.model.{InterfaceIdEntity, TokenInfoEntity} import org.alephium.explorer.persistence.queries.TokenQueries import org.alephium.explorer.persistence.queries.result.TxByTokenQR import org.alephium.explorer.persistence.schema._ import org.alephium.explorer.util.AddressUtil -import org.alephium.util.{TimeStamp, U256} +import org.alephium.util.{Duration, TimeStamp, U256} class TokenQueriesSpec extends AlephiumFutureSpec with DatabaseFixtureForEach with TestDBRunner { "Token Queries" should { + "list tokens with and without interface filters" in { + val now = TimeStamp.now() + val fungibleToken = tokenIdGen.sample.get + val nftToken = tokenIdGen.sample.get + + val rows = Seq( + TokenInfoEntity( + token = fungibleToken, + lastUsed = now.minusUnsafe(Duration.ofHoursUnsafe(1)), + category = Some("0001"), + interfaceId = + Some(InterfaceIdEntity.StdInterfaceIdEntity(StdInterfaceId.FungibleToken.default.id)) + ), + TokenInfoEntity( + token = nftToken, + lastUsed = now, + category = Some("0003"), + interfaceId = Some(InterfaceIdEntity.StdInterfaceIdEntity(StdInterfaceId.NFT.default.id)) + ) + ) + + exec(TokenInfoSchema.table.delete) + exec(TokenInfoSchema.table ++= rows) + + exec( + TokenQueries.listTokensAction(Pagination.unsafe(1, 10), Some(StdInterfaceId.NFT.default)) + ) is ArraySeq(rows(1)) + + exec(TokenQueries.listTokensAction(Pagination.unsafe(1, 10), None)) is ArraySeq( + rows(1), + rows(0) + ) + } + + "return empty metadata lists for empty token inputs" in { + exec(TokenQueries.listFungibleTokenMetadataQuery(ArraySeq.empty)) is ArraySeq.empty + exec(TokenQueries.listNFTMetadataQuery(ArraySeq.empty)) is ArraySeq.empty + } + "list token transactions" in { forAll(Gen.listOfN(30, transactionPerTokenEntityGen()), tokenIdGen) { case (txPerTokens, token) => @@ -80,7 +120,6 @@ class TokenQueriesSpec extends AlephiumFutureSpec with DatabaseFixtureForEach wi } "list address tokens with balance" in { - implicit val groupSetting: GroupSetting = GroupSetting(4) val testData = Gen.nonEmptyListOf(blockAndItsMainChainEntitiesGen()).sample.get val inputs = testData.flatMap(_._1.inputs) val tokenOutputs = testData.map(_._4) diff --git a/app/src/test/scala/org/alephium/explorer/service/SanityCheckerSpec.scala b/app/src/test/scala/org/alephium/explorer/service/SanityCheckerSpec.scala new file mode 100644 index 000000000..3068a2d30 --- /dev/null +++ b/app/src/test/scala/org/alephium/explorer/service/SanityCheckerSpec.scala @@ -0,0 +1,115 @@ +// Copyright (c) Alephium +// SPDX-License-Identifier: LGPL-3.0-only + +package org.alephium.explorer.service + +import java.math.BigInteger + +import scala.collection.immutable.ArraySeq +import scala.concurrent.Future + +import org.apache.pekko.util.ByteString + +import org.alephium.explorer.AlephiumFutureSpec +import org.alephium.explorer.ConfigDefaults._ +import org.alephium.explorer.api.model.Height +import org.alephium.explorer.cache.{BlockCache, TestBlockCache} +import org.alephium.explorer.persistence.DatabaseFixtureForEach +import org.alephium.explorer.persistence.TestDBRunner +import org.alephium.explorer.persistence.dao.BlockDao +import org.alephium.explorer.persistence.model.BlockEntity +import org.alephium.protocol.Hash +import org.alephium.protocol.model.BlockHash +import org.alephium.util.TimeStamp + +class SanityCheckerSpec extends AlephiumFutureSpec with DatabaseFixtureForEach with TestDBRunner { + + private def runningFlag: AnyRef = { + val field = SanityChecker.getClass.getDeclaredField("running") + field.setAccessible(true) + field.get(SanityChecker) + } + + private def setRunning(value: Boolean): Unit = { + val flag = runningFlag + flag.getClass + .getMethod("set", classOf[Boolean]) + .invoke(flag, java.lang.Boolean.valueOf(value)) + () + } + + "check" should { + "download and insert a missing parent block" in { + val chainIndex = groupSetting.chainIndexes.head + val parentHash = BlockHash.unsafe(ByteString.fromArrayUnsafe(Array.fill[Byte](32)(1))) + val childHash = BlockHash.unsafe(ByteString.fromArrayUnsafe(Array.fill[Byte](32)(2))) + val parent = BlockEntity( + hash = parentHash, + timestamp = TimeStamp.unsafe(0), + chainFrom = chainIndex.from, + chainTo = chainIndex.to, + height = Height.unsafe(0), + deps = ArraySeq.empty, + transactions = ArraySeq.empty, + inputs = ArraySeq.empty, + outputs = ArraySeq.empty, + mainChain = true, + nonce = ByteString.empty, + version = 1, + depStateHash = Hash.unsafe(ByteString.fromArrayUnsafe(Array.fill[Byte](32)(3))), + txsHash = Hash.unsafe(ByteString.fromArrayUnsafe(Array.fill[Byte](32)(4))), + target = ByteString.empty, + hashrate = BigInteger.ZERO, + ghostUncles = ArraySeq.empty, + conflictedTxs = None + ) + val child = parent.copy( + hash = childHash, + timestamp = TimeStamp.unsafe(1), + height = Height.unsafe(1), + deps = ArraySeq.fill(groupSetting.groupNum)(parent.hash) + ) + + BlockDao.insert(child).futureValue + + implicit lazy val blockCache: BlockCache = TestBlockCache() + implicit val blockFlowClient: BlockFlowClient = new EmptyBlockFlowClient { + override def fetchBlock( + fromGroup: org.alephium.protocol.model.GroupIndex, + hash: org.alephium.protocol.model.BlockHash + ): Future[org.alephium.explorer.persistence.model.BlockEntity] = + Future.successful(parent.copy(mainChain = true)) + } + + SanityChecker + .check()( + executionContext, + databaseConfig, + blockFlowClient, + blockCache, + groupSetting + ) + .futureValue + + assert(BlockDao.get(child.hash).futureValue.exists(_.mainChain)) + assert(BlockDao.get(parent.hash).futureValue.exists(_.mainChain)) + } + + "return immediately when another run is active" in { + setRunning(true) + try { + SanityChecker + .check()( + executionContext, + databaseConfig, + new EmptyBlockFlowClient {}, + TestBlockCache(), + groupSetting + ) + .futureValue + } finally { + setRunning(false) + } + } + } +} diff --git a/app/src/test/scala/org/alephium/explorer/web/InfosServerSpec.scala b/app/src/test/scala/org/alephium/explorer/web/InfosServerSpec.scala index b28495786..9ac59bd3d 100644 --- a/app/src/test/scala/org/alephium/explorer/web/InfosServerSpec.scala +++ b/app/src/test/scala/org/alephium/explorer/web/InfosServerSpec.scala @@ -3,9 +3,12 @@ package org.alephium.explorer.web +import java.math.BigInteger + import scala.collection.immutable.ArraySeq import scala.concurrent.{ExecutionContext, Future} +import org.apache.pekko.util.ByteString import slick.basic.DatabaseConfig import slick.jdbc.PostgresProfile @@ -16,9 +19,12 @@ import org.alephium.explorer.api.model._ import org.alephium.explorer.cache.{BlockCache, TestBlockCache, TransactionCache} import org.alephium.explorer.config.BootMode import org.alephium.explorer.persistence.{Database, DatabaseFixtureForAll, Migrations} +import org.alephium.explorer.persistence.dao.BlockDao +import org.alephium.explorer.persistence.model.BlockEntity import org.alephium.explorer.service._ -import org.alephium.protocol.ALPH -import org.alephium.util.TimeStamp +import org.alephium.protocol.{ALPH, Hash} +import org.alephium.protocol.model.{BlockHash, ChainIndex, GroupIndex} +import org.alephium.util.{Duration, TimeStamp} @SuppressWarnings(Array("org.wartremover.warts.Var")) class InfosServerSpec() @@ -57,25 +63,6 @@ class InfosServerSpec() ) } - - val chainHeight = PerChainHeight(0, 0, 60000, 60000) - val blockTime = PerChainDuration(0, 0, 1, 1) - val blockService = new EmptyBlockService { - - override def listMaxHeights()(implicit - cache: BlockCache, - groupSetting: GroupSetting, - ec: ExecutionContext - ): Future[ArraySeq[PerChainHeight]] = - Future.successful(ArraySeq(chainHeight)) - - override def getAverageBlockTime()(implicit - cache: BlockCache, - groupSetting: GroupSetting, - ec: ExecutionContext - ): Future[ArraySeq[PerChainDuration]] = - Future.successful(ArraySeq(blockTime)) - } implicit val blockCache: BlockCache = TestBlockCache() implicit val transactionCache: TransactionCache = TransactionCache( new Database(BootMode.ReadWrite) @@ -85,7 +72,7 @@ class InfosServerSpec() } val infoServer = - new InfosServer(tokenSupplyService, blockService, transactionService) + new InfosServer(tokenSupplyService, BlockService, transactionService) val routes = infoServer.routes @@ -101,9 +88,28 @@ class InfosServerSpec() } } - "return chains heights" in { + "return chains heights" in new Fixture { + val chainIndex = + groupSetting.chainIndexes.find(ci => ci.from.value == 1 && ci.to.value == 1).get + insertBlock( + makeBlock( + chainIndex, + height = 3, + timestamp = TimeStamp.now().plusUnsafe(Duration.ofHoursUnsafe(3)) + ) + ) + Get(s"/infos/heights") check { response => - response.as[ArraySeq[PerChainHeight]] is ArraySeq(chainHeight) + response + .as[ArraySeq[PerChainHeight]] + .find(ph => ph.chainFrom == chainIndex.from.value && ph.chainTo == chainIndex.to.value) + .get is + PerChainHeight( + chainIndex.from.value, + chainIndex.to.value, + 3L, + 3L + ) } } @@ -148,9 +154,91 @@ class InfosServerSpec() } } - "return the average block times" in { + "return the average block times" in new Fixture { + seedLatestBlocks(true) + val zeroChainIndex = + groupSetting.chainIndexes + .find(ci => ci.from == GroupIndex.Zero && ci.to == GroupIndex.Zero) + .get + val base = TimeStamp.now().plusUnsafe(Duration.ofHoursUnsafe(3)) + insertBlocks( + makeBlock(zeroChainIndex, height = 0, timestamp = base), + makeBlock( + zeroChainIndex, + height = 1, + timestamp = base.plusUnsafe(Duration.ofMinutesUnsafe(2)) + ), + makeBlock( + zeroChainIndex, + height = 2, + timestamp = base.plusUnsafe(Duration.ofMinutesUnsafe(4)) + ) + ) + Get(s"/infos/average-block-times") check { response => - response.as[ArraySeq[PerChainDuration]] is ArraySeq(blockTime) + response.as[ArraySeq[PerChainDuration]].find(_.chainFrom == 0).get is PerChainDuration( + 0, + 0, + Duration.ofMinutesUnsafe(2).millis, + Duration.ofMinutesUnsafe(2).millis + ) } } + + class Fixture { + def seedLatestBlocks(excludeChainZero: Boolean): Unit = { + groupSetting.chainIndexes.foreach { chainIndex => + if ( + !(excludeChainZero && chainIndex.from == GroupIndex.Zero && chainIndex.to == GroupIndex.Zero) + ) { + val block = makeBlock( + chainIndex, + height = 0, + timestamp = TimeStamp.now().plusUnsafe(Duration.ofHoursUnsafe(3)) + ) + insertBlock(block) + } + } + } + + def insertBlock(block: BlockEntity): Unit = { + BlockDao.insert(block).futureValue + BlockDao.updateLatestBlock(block).futureValue + } + + def insertBlocks(blocks: BlockEntity*): Unit = { + BlockDao.insertAll(ArraySeq.from(blocks)).futureValue + BlockDao.updateLatestBlock(blocks.last).futureValue + } + } + + private def makeBlock( + chainIndex: ChainIndex, + height: Int, + timestamp: TimeStamp + ): BlockEntity = { + val hash = BlockHash.unsafe(ByteString.fromArrayUnsafe(Array.fill[Byte](32)(height.toByte))) + + BlockEntity( + hash = hash, + timestamp = timestamp, + chainFrom = chainIndex.from, + chainTo = chainIndex.to, + height = Height.unsafe(height), + deps = ArraySeq.fill(groupSetting.groupNum)(hash), + transactions = ArraySeq.empty, + inputs = ArraySeq.empty, + outputs = ArraySeq.empty, + mainChain = true, + nonce = ByteString.fromArrayUnsafe(Array.fill[Byte](32)((height + 1).toByte)), + version = 1, + depStateHash = + Hash.unsafe(ByteString.fromArrayUnsafe(Array.fill[Byte](32)((height + 2).toByte))), + txsHash = Hash.unsafe(ByteString.fromArrayUnsafe(Array.fill[Byte](32)((height + 3).toByte))), + target = ByteString.fromArrayUnsafe(Array.fill[Byte](32)((height + 4).toByte)), + hashrate = BigInteger.valueOf(height.toLong), + ghostUncles = ArraySeq.empty, + conflictedTxs = None + ) + } } diff --git a/app/src/test/scala/org/alephium/explorer/web/MetricsServerSpec.scala b/app/src/test/scala/org/alephium/explorer/web/MetricsServerSpec.scala new file mode 100644 index 000000000..b4fabeddd --- /dev/null +++ b/app/src/test/scala/org/alephium/explorer/web/MetricsServerSpec.scala @@ -0,0 +1,45 @@ +// Copyright (c) Alephium +// SPDX-License-Identifier: LGPL-3.0-only + +package org.alephium.explorer.web + +import scala.concurrent.duration._ + +import org.alephium.explorer._ +import org.alephium.explorer.cache.MetricCache +import org.alephium.explorer.config.BootMode +import org.alephium.explorer.persistence.{Database, DatabaseFixtureForAll} + +class MetricsServerSpec + extends AlephiumFutureSpec + with DatabaseFixtureForAll + with HttpServerFixture { + + private val metricCache = new MetricCache(new Database(BootMode.ReadWrite), 1.second)( + executionContext + ) { + override def getFungibleCount(): Int = 7 + override def getNFTCount(): Int = 8 + override def getEventCount(): Int = 9 + } + private val server = new MetricsServer(metricCache) + + override val routes = server.routes + + "metrics" should { + "reload the cache and expose the updated gauges" in { + val response = Get("/metrics") + val body = response.body match { + case Right(text) => text + case Left(error) => fail(error) + } + + body should include("alephimum_explorer_backend_fungible_count") + body should include("alephimum_explorer_backend_nft_count") + body should include("alephimum_explorer_backend_event_count") + body should include("7.0") + body should include("8.0") + body should include("9.0") + } + } +} diff --git a/app/src/test/scala/org/alephium/explorer/web/TokenServerSpec.scala b/app/src/test/scala/org/alephium/explorer/web/TokenServerSpec.scala index 27fb69a4a..bf341993e 100644 --- a/app/src/test/scala/org/alephium/explorer/web/TokenServerSpec.scala +++ b/app/src/test/scala/org/alephium/explorer/web/TokenServerSpec.scala @@ -13,11 +13,13 @@ import slick.jdbc.PostgresProfile import org.alephium.explorer._ import org.alephium.explorer.ConfigDefaults._ import org.alephium.explorer.GenApiModel._ +import org.alephium.explorer.GenCoreProtocol.{addressContractProtocolGen, contractIdGen} import org.alephium.explorer.HttpFixture._ import org.alephium.explorer.api.model._ import org.alephium.explorer.persistence.DatabaseFixtureForAll import org.alephium.explorer.service._ -import org.alephium.protocol.model.TokenId +import org.alephium.protocol.model.{Address, TokenId} +import org.alephium.util.U256 @SuppressWarnings(Array("org.wartremover.warts.Var")) class TokenServerSpec() @@ -25,9 +27,76 @@ class TokenServerSpec() with HttpServerFixture with DatabaseFixtureForAll { - val tokenService = new EmptyTokenService {} + private val tokenInfos = ArraySeq.from( + List( + TokenInfo( + tokenIdGen.sample.get, + Some(StdInterfaceId.FungibleToken.default), + Some("0001"), + Some("0001") + ), + TokenInfo( + tokenIdGen.sample.get, + Some(StdInterfaceId.NFT.default), + Some("0003"), + Some("0003") + ) + ) + ) + + private val tokenTransactions = ArraySeq.from(Gen.listOfN(2, transactionGen).sample.get) + private val tokenAddresses = ArraySeq.from(Gen.listOfN(2, addressGen).sample.get) + private val fungibleMetadata = ArraySeq( + FungibleTokenMetadata(tokenIdGen.sample.get, "alph", "Alephium", U256.Zero) + ) + private val nftMetadata = ArraySeq( + NFTMetadata(tokenIdGen.sample.get, "ipfs://nft", contractIdGen.sample.get, U256.Zero) + ) + private val nftCollectionMetadata = ArraySeq( + NFTCollectionMetadata(addressContractProtocolGen.sample.get, "ipfs://collection") + ) val holdertokens = ArraySeq.from(Gen.listOf(holderInfoGen).sample.get) + val tokenService = new EmptyTokenService { + override def listTokens(pagination: Pagination, interfaceIdOpt: Option[StdInterfaceId])(implicit + ec: ExecutionContext, + dc: DatabaseConfig[PostgresProfile] + ): Future[ArraySeq[TokenInfo]] = + Future.successful(tokenInfos) + + override def listTokenTransactions(token: TokenId, pagination: Pagination)(implicit + ec: ExecutionContext, + dc: DatabaseConfig[PostgresProfile] + ): Future[ArraySeq[Transaction]] = + Future.successful(tokenTransactions) + + override def listTokenInfo(tokens: ArraySeq[TokenId])(implicit + ec: ExecutionContext, + dc: DatabaseConfig[PostgresProfile] + ): Future[ArraySeq[TokenInfo]] = + Future.successful(tokenInfos) + + override def listTokenAddresses(token: TokenId, pagination: Pagination)(implicit + dc: DatabaseConfig[PostgresProfile] + ): Future[ArraySeq[Address]] = + Future.successful(tokenAddresses) + + override def listFungibleTokenMetadata(tokens: ArraySeq[TokenId])(implicit + dc: DatabaseConfig[PostgresProfile] + ): Future[ArraySeq[FungibleTokenMetadata]] = + Future.successful(fungibleMetadata) + + override def listNFTMetadata(tokens: ArraySeq[TokenId])(implicit + dc: DatabaseConfig[PostgresProfile] + ): Future[ArraySeq[NFTMetadata]] = + Future.successful(nftMetadata) + + override def listNFTCollectionMetadata(addresses: ArraySeq[Address.Contract])(implicit + dc: DatabaseConfig[PostgresProfile] + ): Future[ArraySeq[NFTCollectionMetadata]] = + Future.successful(nftCollectionMetadata) + } + val holderService = new EmptyHolderService { override def getAlphHolders(pagination: Pagination)(implicit ec: ExecutionContext, @@ -56,4 +125,42 @@ class TokenServerSpec() response.as[ArraySeq[HolderInfo]] is holdertokens } } + + "return token routes" in { + val tokenIds = ArraySeq.from(List(tokenIdGen.sample.get, tokenIdGen.sample.get)) + val tokenId = tokenIds.head + val contract = addressContractProtocolGen.sample.get + val tokenIdsJson = tokenIds.map(id => s""""${id.toHexString}"""").mkString("[", ",", "]") + val contractJson = s"""["${contract.toString}"]""" + val expectedAddressesJson = + tokenAddresses.map(address => s""""${address.toString}"""").mkString("[", ",", "]") + + Get("/tokens") check { response => + response.as[ArraySeq[TokenInfo]] is tokenInfos + } + + Get(s"/tokens/${tokenId.toHexString}/transactions") check { response => + response.as[ArraySeq[Transaction]] is tokenTransactions + } + + Get(s"/tokens/${tokenId.toHexString}/addresses") check { response => + (response.body.toOption.get == expectedAddressesJson) is true + } + + Post("/tokens", tokenIdsJson) check { response => + response.as[ArraySeq[TokenInfo]] is tokenInfos + } + + Post("/tokens/fungible-metadata", tokenIdsJson) check { response => + response.as[ArraySeq[FungibleTokenMetadata]] is fungibleMetadata + } + + Post("/tokens/nft-metadata", tokenIdsJson) check { response => + response.as[ArraySeq[NFTMetadata]] is nftMetadata + } + + Post("/tokens/nft-collection-metadata", contractJson) check { response => + response.as[ArraySeq[NFTCollectionMetadata]] is nftCollectionMetadata + } + } }