Check if config was reloaded, remove all upstream metrics if it was r… (#327)
This commit is contained in:
@@ -43,9 +43,11 @@ class ReloadConfigSetup(
|
|||||||
try {
|
try {
|
||||||
log.info("Reloading config...")
|
log.info("Reloading config...")
|
||||||
|
|
||||||
reloadConfig()
|
if (reloadConfig()) {
|
||||||
|
log.info("Config is reloaded")
|
||||||
log.info("Config is reloaded")
|
} else {
|
||||||
|
log.info("There is nothing to reload, config is the same")
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
reloadLock.unlock()
|
reloadLock.unlock()
|
||||||
}
|
}
|
||||||
@@ -54,10 +56,14 @@ class ReloadConfigSetup(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun reloadConfig() {
|
private fun reloadConfig(): Boolean {
|
||||||
val newUpstreamsConfig = reloadConfigService.readUpstreamsConfig()
|
val newUpstreamsConfig = reloadConfigService.readUpstreamsConfig()
|
||||||
val currentUpstreamsConfig = reloadConfigService.currentUpstreamsConfig()
|
val currentUpstreamsConfig = reloadConfigService.currentUpstreamsConfig()
|
||||||
|
|
||||||
|
if (newUpstreamsConfig == currentUpstreamsConfig) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
val chainsToReload = analyzeDefaultOptions(
|
val chainsToReload = analyzeDefaultOptions(
|
||||||
currentUpstreamsConfig.defaultOptions,
|
currentUpstreamsConfig.defaultOptions,
|
||||||
newUpstreamsConfig.defaultOptions,
|
newUpstreamsConfig.defaultOptions,
|
||||||
@@ -76,6 +82,8 @@ class ReloadConfigSetup(
|
|||||||
reloadConfigUpstreamService.reloadUpstreams(chainsToReload, upstreamsToRemove, upstreamsToAdd, newUpstreamsConfig)
|
reloadConfigUpstreamService.reloadUpstreams(chainsToReload, upstreamsToRemove, upstreamsToAdd, newUpstreamsConfig)
|
||||||
|
|
||||||
reloadConfigService.updateUpstreamsConfig(newUpstreamsConfig)
|
reloadConfigService.updateUpstreamsConfig(newUpstreamsConfig)
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun analyzeUpstreams(
|
private fun analyzeUpstreams(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import org.springframework.context.ApplicationEventPublisher
|
|||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class ReloadConfigUpstreamService(
|
open class ReloadConfigUpstreamService(
|
||||||
private val eventPublisher: ApplicationEventPublisher,
|
private val eventPublisher: ApplicationEventPublisher,
|
||||||
private val multistreamHolder: CurrentMultistreamHolder,
|
private val multistreamHolder: CurrentMultistreamHolder,
|
||||||
private val configuredUpstreams: ConfiguredUpstreams,
|
private val configuredUpstreams: ConfiguredUpstreams,
|
||||||
|
|||||||
@@ -4,3 +4,7 @@ import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest
|
|||||||
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
|
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse
|
||||||
|
|
||||||
typealias JsonRpcReader = Reader<JsonRpcRequest, JsonRpcResponse>
|
typealias JsonRpcReader = Reader<JsonRpcRequest, JsonRpcResponse>
|
||||||
|
|
||||||
|
interface JsonRpcHttpReader : JsonRpcReader {
|
||||||
|
fun onStop()
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import io.emeraldpay.dshackle.config.UpstreamsConfig.HttpEndpoint
|
|||||||
import io.emeraldpay.dshackle.config.UpstreamsConfig.RpcConnection
|
import io.emeraldpay.dshackle.config.UpstreamsConfig.RpcConnection
|
||||||
import io.emeraldpay.dshackle.foundation.ChainOptions
|
import io.emeraldpay.dshackle.foundation.ChainOptions
|
||||||
import io.emeraldpay.dshackle.foundation.ChainOptions.Options
|
import io.emeraldpay.dshackle.foundation.ChainOptions.Options
|
||||||
import io.emeraldpay.dshackle.reader.JsonRpcReader
|
|
||||||
import io.emeraldpay.dshackle.upstream.BlockValidator
|
import io.emeraldpay.dshackle.upstream.BlockValidator
|
||||||
import io.emeraldpay.dshackle.upstream.CallTargetsHolder
|
import io.emeraldpay.dshackle.upstream.CallTargetsHolder
|
||||||
import io.emeraldpay.dshackle.upstream.Head
|
import io.emeraldpay.dshackle.upstream.Head
|
||||||
@@ -343,7 +342,7 @@ open class ConfiguredUpstreams(
|
|||||||
log.warn("Upstream doesn't have API configuration")
|
log.warn("Upstream doesn't have API configuration")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val directApi: JsonRpcReader = httpFactory.create(config.id, chain)
|
val directApi = httpFactory.create(config.id, chain)
|
||||||
val esplora = conn.esplora?.let { endpoint ->
|
val esplora = conn.esplora?.let { endpoint ->
|
||||||
val tls = endpoint.tls?.let { tls ->
|
val tls = endpoint.tls?.let { tls ->
|
||||||
tls.ca?.let { ca ->
|
tls.ca?.let { ca ->
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ package io.emeraldpay.dshackle.upstream
|
|||||||
import io.emeraldpay.dshackle.data.BlockContainer
|
import io.emeraldpay.dshackle.data.BlockContainer
|
||||||
import io.emeraldpay.dshackle.upstream.forkchoice.ForkChoice
|
import io.emeraldpay.dshackle.upstream.forkchoice.ForkChoice
|
||||||
import io.micrometer.core.instrument.Gauge
|
import io.micrometer.core.instrument.Gauge
|
||||||
|
import io.micrometer.core.instrument.Meter
|
||||||
import io.micrometer.core.instrument.Metrics
|
import io.micrometer.core.instrument.Metrics
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import reactor.core.Disposable
|
import reactor.core.Disposable
|
||||||
@@ -32,7 +33,6 @@ import java.util.concurrent.Executors
|
|||||||
import java.util.concurrent.Future
|
import java.util.concurrent.Future
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
|
||||||
abstract class AbstractHead @JvmOverloads constructor(
|
abstract class AbstractHead @JvmOverloads constructor(
|
||||||
@@ -57,14 +57,24 @@ abstract class AbstractHead @JvmOverloads constructor(
|
|||||||
private var future: Future<*>? = null
|
private var future: Future<*>? = null
|
||||||
private val delayed = AtomicBoolean(false)
|
private val delayed = AtomicBoolean(false)
|
||||||
|
|
||||||
|
private val metrics = mutableSetOf<Meter>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val className = this.javaClass.simpleName
|
val className = this.javaClass.simpleName
|
||||||
Gauge.builder("stuck_head", delayed) {
|
Gauge.builder("stuck_head", delayed) {
|
||||||
if (it.get()) 1.0 else 0.0
|
if (it.get()) 1.0 else 0.0
|
||||||
}.tag("upstream", upstreamId).tag("class", className).register(Metrics.globalRegistry)
|
}
|
||||||
|
.tag("upstream", upstreamId)
|
||||||
|
.tag("class", className)
|
||||||
|
.register(Metrics.globalRegistry)
|
||||||
|
.also { metrics.add(it) }
|
||||||
Gauge.builder("current_head", forkChoice) {
|
Gauge.builder("current_head", forkChoice) {
|
||||||
it.getHead()?.height?.toDouble() ?: 0.0
|
it.getHead()?.height?.toDouble() ?: 0.0
|
||||||
}.tag("upstream", upstreamId).tag("class", className).register(Metrics.globalRegistry)
|
}
|
||||||
|
.tag("upstream", upstreamId)
|
||||||
|
.tag("class", className)
|
||||||
|
.register(Metrics.globalRegistry)
|
||||||
|
.also { metrics.add(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun follow(source: Flux<BlockContainer>): Disposable {
|
fun follow(source: Flux<BlockContainer>): Disposable {
|
||||||
@@ -147,6 +157,7 @@ abstract class AbstractHead @JvmOverloads constructor(
|
|||||||
it.cancel(true)
|
it.cancel(true)
|
||||||
}
|
}
|
||||||
future = null
|
future = null
|
||||||
|
metrics.forEach { Metrics.globalRegistry.remove(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
protected open fun onNoHeadUpdates() {
|
protected open fun onNoHeadUpdates() {
|
||||||
@@ -176,10 +187,4 @@ abstract class AbstractHead @JvmOverloads constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toHeadCountMetric(counter: AtomicInteger, status: String) {
|
|
||||||
Gauge.builder("head_count", counter) {
|
|
||||||
it.get().toDouble()
|
|
||||||
}.tag("class", this.javaClass.simpleName).tag("status", status).register(Metrics.globalRegistry)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package io.emeraldpay.dshackle.upstream
|
package io.emeraldpay.dshackle.upstream
|
||||||
|
|
||||||
import io.emeraldpay.dshackle.Chain
|
import io.emeraldpay.dshackle.Chain
|
||||||
import io.emeraldpay.dshackle.reader.JsonRpcReader
|
import io.emeraldpay.dshackle.reader.JsonRpcHttpReader
|
||||||
|
|
||||||
interface HttpFactory {
|
interface HttpFactory {
|
||||||
fun create(id: String?, chain: Chain): JsonRpcReader
|
fun create(id: String?, chain: Chain): JsonRpcHttpReader
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package io.emeraldpay.dshackle.upstream
|
|||||||
|
|
||||||
import io.emeraldpay.dshackle.Chain
|
import io.emeraldpay.dshackle.Chain
|
||||||
import io.emeraldpay.dshackle.config.AuthConfig
|
import io.emeraldpay.dshackle.config.AuthConfig
|
||||||
import io.emeraldpay.dshackle.reader.JsonRpcReader
|
import io.emeraldpay.dshackle.reader.JsonRpcHttpReader
|
||||||
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcHttpClient
|
import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcHttpClient
|
||||||
import io.emeraldpay.dshackle.upstream.rpcclient.RpcMetrics
|
import io.emeraldpay.dshackle.upstream.rpcclient.RpcMetrics
|
||||||
import io.micrometer.core.instrument.Counter
|
import io.micrometer.core.instrument.Counter
|
||||||
@@ -15,7 +15,7 @@ open class HttpRpcFactory(
|
|||||||
private val basicAuth: AuthConfig.ClientBasicAuth?,
|
private val basicAuth: AuthConfig.ClientBasicAuth?,
|
||||||
private val tls: ByteArray?,
|
private val tls: ByteArray?,
|
||||||
) : HttpFactory {
|
) : HttpFactory {
|
||||||
override fun create(id: String?, chain: Chain): JsonRpcReader {
|
override fun create(id: String?, chain: Chain): JsonRpcHttpReader {
|
||||||
val metricsTags = listOf(
|
val metricsTags = listOf(
|
||||||
// "unknown" is not supposed to happen
|
// "unknown" is not supposed to happen
|
||||||
Tag.of("upstream", id ?: "unknown"),
|
Tag.of("upstream", id ?: "unknown"),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import io.emeraldpay.dshackle.Chain
|
|||||||
import io.emeraldpay.dshackle.config.ChainsConfig
|
import io.emeraldpay.dshackle.config.ChainsConfig
|
||||||
import io.emeraldpay.dshackle.config.UpstreamsConfig
|
import io.emeraldpay.dshackle.config.UpstreamsConfig
|
||||||
import io.emeraldpay.dshackle.foundation.ChainOptions
|
import io.emeraldpay.dshackle.foundation.ChainOptions
|
||||||
|
import io.emeraldpay.dshackle.reader.JsonRpcHttpReader
|
||||||
import io.emeraldpay.dshackle.reader.JsonRpcReader
|
import io.emeraldpay.dshackle.reader.JsonRpcReader
|
||||||
import io.emeraldpay.dshackle.startup.QuorumForLabels
|
import io.emeraldpay.dshackle.startup.QuorumForLabels
|
||||||
import io.emeraldpay.dshackle.upstream.Capability
|
import io.emeraldpay.dshackle.upstream.Capability
|
||||||
@@ -32,7 +33,7 @@ import reactor.core.Disposable
|
|||||||
open class BitcoinRpcUpstream(
|
open class BitcoinRpcUpstream(
|
||||||
id: String,
|
id: String,
|
||||||
chain: Chain,
|
chain: Chain,
|
||||||
private val directApi: JsonRpcReader,
|
private val directApi: JsonRpcHttpReader,
|
||||||
private val head: Head,
|
private val head: Head,
|
||||||
options: ChainOptions.Options,
|
options: ChainOptions.Options,
|
||||||
role: UpstreamsConfig.UpstreamRole,
|
role: UpstreamsConfig.UpstreamRole,
|
||||||
@@ -115,5 +116,6 @@ open class BitcoinRpcUpstream(
|
|||||||
head.stop()
|
head.stop()
|
||||||
}
|
}
|
||||||
validatorSubscription?.dispose()
|
validatorSubscription?.dispose()
|
||||||
|
directApi.onStop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package io.emeraldpay.dshackle.upstream.generic.connectors
|
|||||||
|
|
||||||
import io.emeraldpay.dshackle.cache.Caches
|
import io.emeraldpay.dshackle.cache.Caches
|
||||||
import io.emeraldpay.dshackle.cache.CachesEnabled
|
import io.emeraldpay.dshackle.cache.CachesEnabled
|
||||||
|
import io.emeraldpay.dshackle.reader.JsonRpcHttpReader
|
||||||
import io.emeraldpay.dshackle.reader.JsonRpcReader
|
import io.emeraldpay.dshackle.reader.JsonRpcReader
|
||||||
import io.emeraldpay.dshackle.upstream.BlockValidator
|
import io.emeraldpay.dshackle.upstream.BlockValidator
|
||||||
import io.emeraldpay.dshackle.upstream.DefaultUpstream
|
import io.emeraldpay.dshackle.upstream.DefaultUpstream
|
||||||
@@ -31,7 +32,7 @@ import java.time.Duration
|
|||||||
|
|
||||||
class GenericRpcConnector(
|
class GenericRpcConnector(
|
||||||
connectorType: ConnectorMode,
|
connectorType: ConnectorMode,
|
||||||
private val directReader: JsonRpcReader,
|
private val directReader: JsonRpcHttpReader,
|
||||||
wsFactory: WsConnectionPoolFactory?,
|
wsFactory: WsConnectionPoolFactory?,
|
||||||
upstream: DefaultUpstream,
|
upstream: DefaultUpstream,
|
||||||
forkChoice: ForkChoice,
|
forkChoice: ForkChoice,
|
||||||
@@ -137,6 +138,7 @@ class GenericRpcConnector(
|
|||||||
head.stop()
|
head.stop()
|
||||||
}
|
}
|
||||||
pool?.close()
|
pool?.close()
|
||||||
|
directReader.onStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getIngressReader(): JsonRpcReader {
|
override fun getIngressReader(): JsonRpcReader {
|
||||||
|
|||||||
@@ -16,9 +16,10 @@
|
|||||||
package io.emeraldpay.dshackle.upstream.rpcclient
|
package io.emeraldpay.dshackle.upstream.rpcclient
|
||||||
|
|
||||||
import io.emeraldpay.dshackle.config.AuthConfig
|
import io.emeraldpay.dshackle.config.AuthConfig
|
||||||
import io.emeraldpay.dshackle.reader.JsonRpcReader
|
import io.emeraldpay.dshackle.reader.JsonRpcHttpReader
|
||||||
import io.emeraldpay.etherjar.rpc.RpcException
|
import io.emeraldpay.etherjar.rpc.RpcException
|
||||||
import io.emeraldpay.etherjar.rpc.RpcResponseError
|
import io.emeraldpay.etherjar.rpc.RpcResponseError
|
||||||
|
import io.micrometer.core.instrument.Metrics
|
||||||
import io.netty.buffer.Unpooled
|
import io.netty.buffer.Unpooled
|
||||||
import io.netty.handler.codec.http.HttpHeaderNames
|
import io.netty.handler.codec.http.HttpHeaderNames
|
||||||
import io.netty.handler.codec.http.HttpHeaders
|
import io.netty.handler.codec.http.HttpHeaders
|
||||||
@@ -47,7 +48,7 @@ class JsonRpcHttpClient(
|
|||||||
private val metrics: RpcMetrics,
|
private val metrics: RpcMetrics,
|
||||||
basicAuth: AuthConfig.ClientBasicAuth? = null,
|
basicAuth: AuthConfig.ClientBasicAuth? = null,
|
||||||
tlsCAAuth: ByteArray? = null,
|
tlsCAAuth: ByteArray? = null,
|
||||||
) : JsonRpcReader {
|
) : JsonRpcHttpReader {
|
||||||
|
|
||||||
private val parser = ResponseRpcParser()
|
private val parser = ResponseRpcParser()
|
||||||
private val httpClient: HttpClient
|
private val httpClient: HttpClient
|
||||||
@@ -104,6 +105,11 @@ class JsonRpcHttpClient(
|
|||||||
}.single()
|
}.single()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
Metrics.globalRegistry.remove(metrics.timer)
|
||||||
|
Metrics.globalRegistry.remove(metrics.fails)
|
||||||
|
}
|
||||||
|
|
||||||
override fun read(key: JsonRpcRequest): Mono<JsonRpcResponse> {
|
override fun read(key: JsonRpcRequest): Mono<JsonRpcResponse> {
|
||||||
val startTime = StopWatch()
|
val startTime = StopWatch()
|
||||||
return Mono.just(key)
|
return Mono.just(key)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ package io.emeraldpay.dshackle.upstream
|
|||||||
import io.emeraldpay.dshackle.Chain
|
import io.emeraldpay.dshackle.Chain
|
||||||
import io.emeraldpay.dshackle.config.ChainsConfig
|
import io.emeraldpay.dshackle.config.ChainsConfig
|
||||||
import io.emeraldpay.dshackle.config.UpstreamsConfig
|
import io.emeraldpay.dshackle.config.UpstreamsConfig
|
||||||
|
import io.emeraldpay.dshackle.reader.JsonRpcHttpReader
|
||||||
import io.emeraldpay.dshackle.startup.QuorumForLabels
|
import io.emeraldpay.dshackle.startup.QuorumForLabels
|
||||||
import io.emeraldpay.dshackle.test.EthereumApiStub
|
import io.emeraldpay.dshackle.test.EthereumApiStub
|
||||||
import io.emeraldpay.dshackle.test.TestingCommons
|
import io.emeraldpay.dshackle.test.TestingCommons
|
||||||
@@ -51,7 +52,7 @@ class FilteredApisSpec extends Specification {
|
|||||||
[test: "baz"]
|
[test: "baz"]
|
||||||
].collect {
|
].collect {
|
||||||
def httpFactory = Mock(HttpFactory) {
|
def httpFactory = Mock(HttpFactory) {
|
||||||
create(_, _) >> TestingCommons.api().tap { it.id = "${i++}" }
|
create(_, _) >> Stub(JsonRpcHttpReader)
|
||||||
}
|
}
|
||||||
def connectorFactory = new GenericConnectorFactory(
|
def connectorFactory = new GenericConnectorFactory(
|
||||||
GenericConnectorFactory.ConnectorMode.RPC_ONLY,
|
GenericConnectorFactory.ConnectorMode.RPC_ONLY,
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import org.junit.jupiter.api.Assertions.assertEquals
|
|||||||
import org.junit.jupiter.api.BeforeEach
|
import org.junit.jupiter.api.BeforeEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.mockito.ArgumentCaptor
|
import org.mockito.ArgumentCaptor
|
||||||
|
import org.mockito.kotlin.any
|
||||||
import org.mockito.kotlin.doReturn
|
import org.mockito.kotlin.doReturn
|
||||||
import org.mockito.kotlin.mock
|
import org.mockito.kotlin.mock
|
||||||
|
import org.mockito.kotlin.never
|
||||||
import org.mockito.kotlin.times
|
import org.mockito.kotlin.times
|
||||||
import org.mockito.kotlin.verify
|
import org.mockito.kotlin.verify
|
||||||
import org.mockito.kotlin.whenever
|
import org.mockito.kotlin.whenever
|
||||||
@@ -99,7 +101,7 @@ class ReloadConfigTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `stop multistream of there are no upstreams left`() {
|
fun `stop multistream if there are no upstreams left`() {
|
||||||
val up1 = upstream("local1")
|
val up1 = upstream("local1")
|
||||||
val up2 = upstream("local2")
|
val up2 = upstream("local2")
|
||||||
val up3 = upstream("local3")
|
val up3 = upstream("local3")
|
||||||
@@ -154,6 +156,25 @@ class ReloadConfigTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reload the same config cause to nothing`() {
|
||||||
|
val initialConfigFile = ResourceUtils.getFile("classpath:configs/upstreams-initial.yaml")
|
||||||
|
val initialConfig = upstreamsConfigReader.read(initialConfigFile.inputStream())!!
|
||||||
|
mainConfig.upstreams = initialConfig
|
||||||
|
|
||||||
|
val reloadConfigUpstreamService = mock<ReloadConfigUpstreamService>()
|
||||||
|
|
||||||
|
val reloadConfig = ReloadConfigSetup(reloadConfigService, reloadConfigUpstreamService)
|
||||||
|
|
||||||
|
whenever(config.getConfigPath()).thenReturn(initialConfigFile)
|
||||||
|
|
||||||
|
reloadConfig.handle(Signal("HUP"))
|
||||||
|
|
||||||
|
verify(reloadConfigUpstreamService, never()).reloadUpstreams(any(), any(), any(), any())
|
||||||
|
|
||||||
|
assertEquals(initialConfig, mainConfig.upstreams)
|
||||||
|
}
|
||||||
|
|
||||||
private fun upstream(id: String): Upstream =
|
private fun upstream(id: String): Upstream =
|
||||||
mock {
|
mock {
|
||||||
on { getId() } doReturn id
|
on { getId() } doReturn id
|
||||||
|
|||||||
Reference in New Issue
Block a user