публикация одной outbox-записи паспорта маршрута
- добавить надёжные настройки Kafka producer - публиковать один record из RoutePassportOutboxEntity с timeout - сериализовать JsonNode payload напрямую как JSON object - пробрасывать ошибки Kafka send вызывающему коду - обновить тесты publisher и producer config
This commit is contained in:
@@ -38,6 +38,11 @@ spring:
|
|||||||
producer:
|
producer:
|
||||||
key-serializer: org.apache.kafka.common.serialization.StringSerializer
|
key-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||||
value-serializer: org.apache.kafka.common.serialization.StringSerializer
|
value-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||||
|
acks: all
|
||||||
|
retries: 10
|
||||||
|
properties:
|
||||||
|
enable.idempotence: true
|
||||||
|
max.in.flight.requests.per.connection: 5
|
||||||
listener:
|
listener:
|
||||||
missing-topics-fatal: false
|
missing-topics-fatal: false
|
||||||
ack-mode: manual_immediate
|
ack-mode: manual_immediate
|
||||||
|
|||||||
+16
@@ -21,6 +21,18 @@ import org.springframework.kafka.core.ProducerFactory
|
|||||||
class KafkaProducerConfig(
|
class KafkaProducerConfig(
|
||||||
@param:Value("\${spring.kafka.bootstrap-servers}")
|
@param:Value("\${spring.kafka.bootstrap-servers}")
|
||||||
private val bootstrapServers: String,
|
private val bootstrapServers: String,
|
||||||
|
|
||||||
|
@param:Value("\${spring.kafka.producer.acks:all}")
|
||||||
|
private val acks: String,
|
||||||
|
|
||||||
|
@param:Value("\${spring.kafka.producer.retries:10}")
|
||||||
|
private val retries: Int,
|
||||||
|
|
||||||
|
@param:Value("\${spring.kafka.producer.properties.enable.idempotence:true}")
|
||||||
|
private val enableIdempotence: Boolean,
|
||||||
|
|
||||||
|
@param:Value("\${spring.kafka.producer.properties.max.in.flight.requests.per.connection:5}")
|
||||||
|
private val maxInFlightRequestsPerConnection: Int,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@@ -30,6 +42,10 @@ class KafkaProducerConfig(
|
|||||||
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
|
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
|
||||||
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
|
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
|
||||||
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
|
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
|
||||||
|
ProducerConfig.ACKS_CONFIG to acks,
|
||||||
|
ProducerConfig.RETRIES_CONFIG to retries,
|
||||||
|
ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG to enableIdempotence,
|
||||||
|
ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION to maxInFlightRequestsPerConnection,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+52
-85
@@ -3,111 +3,78 @@ package space.nstart.pcp.routepassportconsumer.service
|
|||||||
import org.apache.kafka.clients.producer.ProducerRecord
|
import org.apache.kafka.clients.producer.ProducerRecord
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.kafka.core.KafkaTemplate
|
import org.springframework.kafka.core.KafkaTemplate
|
||||||
import org.springframework.kafka.support.SendResult
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties
|
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxEntity
|
||||||
import space.nstart.pcp.pcp_types_lib.dto.routes.ParsedRoutePassportMessage
|
|
||||||
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
|
|
||||||
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
|
|
||||||
import tools.jackson.databind.ObjectMapper
|
import tools.jackson.databind.ObjectMapper
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.ExecutionException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сервис публикации обработанного маршрута в Kafka.
|
* Сервис физической публикации одной готовой outbox-записи в Kafka.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class RoutePassportKafkaPublisherService(
|
class RoutePassportKafkaPublisherService(
|
||||||
/** ObjectMapper сериализует итоговый DTO в JSON перед отправкой. */
|
/** ObjectMapper сериализует сохранённый JsonNode payload в JSON object string перед отправкой. */
|
||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
|
|
||||||
/** KafkaTemplate отправляет одно и то же сообщение в несколько topic'ов. */
|
/** KafkaTemplate отправляет готовую outbox-запись в её целевой topic. */
|
||||||
private val kafkaTemplate: KafkaTemplate<String, String>,
|
private val kafkaTemplate: KafkaTemplate<String, String>,
|
||||||
|
|
||||||
/** Набор Kafka topic'ов, в которые публикуется результат обработки. */
|
|
||||||
private val kafkaTopicsProperties: KafkaTopicsProperties,
|
|
||||||
) {
|
) {
|
||||||
private val log = LoggerFactory.getLogger(this::class.java)
|
private val log = LoggerFactory.getLogger(this::class.java)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Публикует полный RoutePassportDto в оба выходных topic'а.
|
* Публикует ровно одну outbox-запись и пробрасывает ошибку отправки вызывающему коду.
|
||||||
*/
|
*/
|
||||||
fun publish(parsedMessage: ParsedRoutePassportMessage) {
|
fun publish(outbox: RoutePassportOutboxEntity) {
|
||||||
val routePassportDto = parsedMessage.routePassportDto
|
val payload = objectMapper.writeValueAsString(outbox.payload)
|
||||||
|
val record = ProducerRecord(outbox.topic, outbox.messageKey, payload)
|
||||||
|
record.headers().add(TYPE_HEADER, outbox.eventType.toByteArray(StandardCharsets.UTF_8))
|
||||||
|
|
||||||
log.info(
|
try {
|
||||||
"Publishing route passport: routeNameFull={}, processedRouteTopic={}, routeGeoreferenceTopic={}",
|
val result = kafkaTemplate.send(record)
|
||||||
routePassportDto.routeNameFull,
|
.get(DEFAULT_PUBLISH_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
kafkaTopicsProperties.processedRoute,
|
|
||||||
kafkaTopicsProperties.routeGeoreference,
|
|
||||||
)
|
|
||||||
|
|
||||||
sendWithLogging(
|
log.info(
|
||||||
topic = kafkaTopicsProperties.processedRoute,
|
"Kafka outbox message published: outboxId={}, routeId={}, eventType={}, topic={}, partition={}, offset={}",
|
||||||
eventType = PcpKafkaEvent.ModeStatusChangedEvent,
|
outbox.id,
|
||||||
payload = buildPayload(
|
outbox.routeId,
|
||||||
eventType = PcpKafkaEvent.ModeStatusChangedEvent,
|
outbox.eventType,
|
||||||
parsedMessage = parsedMessage,
|
outbox.topic,
|
||||||
),
|
result.recordMetadata.partition(),
|
||||||
traceId = parsedMessage.metadata.traceId,
|
result.recordMetadata.offset(),
|
||||||
routeNameFull = routePassportDto.routeNameFull,
|
)
|
||||||
)
|
} catch (exception: InterruptedException) {
|
||||||
sendWithLogging(
|
Thread.currentThread().interrupt()
|
||||||
topic = kafkaTopicsProperties.routeGeoreference,
|
logPublishFailure(outbox, exception)
|
||||||
eventType = PcpKafkaEvent.RouteGeoRefEvent,
|
throw exception
|
||||||
payload = buildPayload(
|
} catch (exception: ExecutionException) {
|
||||||
eventType = PcpKafkaEvent.RouteGeoRefEvent,
|
val failure = exception.cause
|
||||||
parsedMessage = parsedMessage,
|
logPublishFailure(outbox, failure ?: exception)
|
||||||
),
|
|
||||||
traceId = parsedMessage.metadata.traceId,
|
|
||||||
routeNameFull = routePassportDto.routeNameFull,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildPayload(eventType: PcpKafkaEvent, parsedMessage: ParsedRoutePassportMessage): String {
|
when (failure) {
|
||||||
val message = KafkaMessage(
|
is Exception -> throw failure
|
||||||
type = eventType,
|
else -> throw exception
|
||||||
data = parsedMessage.routePassportDto,
|
|
||||||
traceId = parsedMessage.metadata.traceId,
|
|
||||||
correlationId = parsedMessage.metadata.correlationId,
|
|
||||||
group = "pcp",
|
|
||||||
).apply {
|
|
||||||
source = "route-processing"
|
|
||||||
}
|
|
||||||
|
|
||||||
return objectMapper.writeValueAsString(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendWithLogging(
|
|
||||||
topic: String,
|
|
||||||
eventType: PcpKafkaEvent,
|
|
||||||
payload: String,
|
|
||||||
traceId: String,
|
|
||||||
routeNameFull: String,
|
|
||||||
) {
|
|
||||||
val record = ProducerRecord(topic, traceId, payload)
|
|
||||||
record.headers().add("type", eventType.name.toByteArray(StandardCharsets.UTF_8))
|
|
||||||
|
|
||||||
val future: CompletableFuture<SendResult<String, String>> = kafkaTemplate.send(record)
|
|
||||||
future.whenComplete { result, exception ->
|
|
||||||
if (exception == null) {
|
|
||||||
log.info(
|
|
||||||
"Kafka message published: routeNameFull={}, topic={}, type={}, partition={}, offset={}",
|
|
||||||
routeNameFull,
|
|
||||||
topic,
|
|
||||||
eventType,
|
|
||||||
result.recordMetadata.partition(),
|
|
||||||
result.recordMetadata.offset(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
log.error(
|
|
||||||
"Failed to publish kafka message: routeNameFull={}, topic={}, type={}",
|
|
||||||
routeNameFull,
|
|
||||||
topic,
|
|
||||||
eventType,
|
|
||||||
exception,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
logPublishFailure(outbox, exception)
|
||||||
|
throw exception
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun logPublishFailure(outbox: RoutePassportOutboxEntity, exception: Throwable) {
|
||||||
|
log.error(
|
||||||
|
"Failed to publish kafka outbox message: outboxId={}, routeId={}, eventType={}, topic={}",
|
||||||
|
outbox.id,
|
||||||
|
outbox.routeId,
|
||||||
|
outbox.eventType,
|
||||||
|
outbox.topic,
|
||||||
|
exception,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TYPE_HEADER = "type"
|
||||||
|
const val DEFAULT_PUBLISH_TIMEOUT_SECONDS = 10L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package space.nstart.pcp.routepassportconsumer.config
|
||||||
|
|
||||||
|
import org.apache.kafka.clients.producer.ProducerConfig
|
||||||
|
import org.apache.kafka.common.serialization.StringSerializer
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.kafka.core.DefaultKafkaProducerFactory
|
||||||
|
|
||||||
|
class KafkaProducerConfigTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `producer factory applies reliability settings`() {
|
||||||
|
val config = KafkaProducerConfig(
|
||||||
|
bootstrapServers = "localhost:9092",
|
||||||
|
acks = "all",
|
||||||
|
retries = 10,
|
||||||
|
enableIdempotence = true,
|
||||||
|
maxInFlightRequestsPerConnection = 5,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val producerFactory = config.producerFactory() as DefaultKafkaProducerFactory<String, String>
|
||||||
|
val properties = producerFactory.configurationProperties
|
||||||
|
|
||||||
|
assertThat(properties)
|
||||||
|
.containsEntry(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092")
|
||||||
|
.containsEntry(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer::class.java)
|
||||||
|
.containsEntry(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer::class.java)
|
||||||
|
.containsEntry(ProducerConfig.ACKS_CONFIG, "all")
|
||||||
|
.containsEntry(ProducerConfig.RETRIES_CONFIG, 10)
|
||||||
|
.containsEntry(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true)
|
||||||
|
.containsEntry(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
+138
-110
@@ -1,6 +1,8 @@
|
|||||||
package space.nstart.pcp.routepassportconsumer.service
|
package space.nstart.pcp.routepassportconsumer.service
|
||||||
|
|
||||||
import org.apache.kafka.clients.producer.ProducerRecord
|
import org.apache.kafka.clients.producer.ProducerRecord
|
||||||
|
import org.apache.kafka.clients.producer.RecordMetadata
|
||||||
|
import org.apache.kafka.common.TopicPartition
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.mockito.ArgumentCaptor
|
import org.mockito.ArgumentCaptor
|
||||||
import org.mockito.ArgumentMatchers.any
|
import org.mockito.ArgumentMatchers.any
|
||||||
@@ -11,134 +13,160 @@ import org.mockito.Mockito.times
|
|||||||
import org.mockito.Mockito.verify
|
import org.mockito.Mockito.verify
|
||||||
import org.springframework.kafka.core.KafkaTemplate
|
import org.springframework.kafka.core.KafkaTemplate
|
||||||
import org.springframework.kafka.support.SendResult
|
import org.springframework.kafka.support.SendResult
|
||||||
import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties
|
|
||||||
import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto
|
|
||||||
import space.nstart.pcp.pcp_types_lib.dto.routes.ParsedRoutePassportMessage
|
|
||||||
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto
|
|
||||||
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportMessageMetadata
|
|
||||||
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
|
|
||||||
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
|
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
|
||||||
import java.math.BigDecimal
|
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxEntity
|
||||||
|
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxStatus
|
||||||
|
import tools.jackson.databind.ObjectMapper
|
||||||
|
import tools.jackson.databind.json.JsonMapper
|
||||||
|
import tools.jackson.module.kotlin.KotlinModule
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.CompletableFuture
|
||||||
import tools.jackson.databind.ObjectMapper
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertSame
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
|
||||||
class RoutePassportKafkaPublisherServiceTest {
|
class RoutePassportKafkaPublisherServiceTest {
|
||||||
|
|
||||||
private fun anyProducerRecord(): ProducerRecord<String, String> {
|
private val objectMapper: ObjectMapper = JsonMapper.builder()
|
||||||
@Suppress("UNCHECKED_CAST")
|
.addModule(KotlinModule.Builder().build())
|
||||||
return (any(ProducerRecord::class.java) as ProducerRecord<String, String>?)
|
.build()
|
||||||
?: ProducerRecord("stub-topic", "stub-key", "stub-payload")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun captureProducerRecord(
|
@Test
|
||||||
captor: ArgumentCaptor<ProducerRecord<String, String>>,
|
fun `successful publish sends one outbox record with topic key payload and type header`() {
|
||||||
): ProducerRecord<String, String> {
|
val kafkaTemplate = kafkaTemplate()
|
||||||
return captor.capture() ?: ProducerRecord("stub-topic", "stub-key", "stub-payload")
|
val publisherService = publisherService(kafkaTemplate)
|
||||||
|
val outbox = outbox()
|
||||||
|
|
||||||
|
doAnswer { invocation ->
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val record = invocation.arguments[0] as ProducerRecord<String, String>
|
||||||
|
CompletableFuture.completedFuture(sendResult(record))
|
||||||
|
}.`when`(kafkaTemplate).send(anyProducerRecord())
|
||||||
|
|
||||||
|
publisherService.publish(outbox)
|
||||||
|
|
||||||
|
val record = captureSingleRecord(kafkaTemplate)
|
||||||
|
assertEquals(outbox.topic, record.topic())
|
||||||
|
assertEquals(outbox.messageKey, record.key())
|
||||||
|
assertEquals(objectMapper.writeValueAsString(outbox.payload), record.value())
|
||||||
|
assertEquals(
|
||||||
|
outbox.eventType,
|
||||||
|
record.headers().lastHeader("type").value().toString(StandardCharsets.UTF_8),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `should publish kafka message to both output topics with dedicated event types`() {
|
fun `send failure is propagated to caller`() {
|
||||||
val objectMapper = mock(ObjectMapper::class.java)
|
val kafkaTemplate = kafkaTemplate()
|
||||||
@Suppress("UNCHECKED_CAST")
|
val publisherService = publisherService(kafkaTemplate)
|
||||||
val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate<String, String>
|
val outbox = outbox()
|
||||||
val kafkaTopicsProperties = KafkaTopicsProperties(
|
val failure = IllegalStateException("broker unavailable")
|
||||||
input = "pcp.request.survey-georeference.v1",
|
val failedFuture = CompletableFuture<SendResult<String, String>>()
|
||||||
processedRoute = "pcp.route.in.v1",
|
.apply { completeExceptionally(failure) }
|
||||||
routeGeoreference = "pcp.route.georeference.v1",
|
|
||||||
)
|
|
||||||
val publisherService = RoutePassportKafkaPublisherService(
|
|
||||||
objectMapper = objectMapper,
|
|
||||||
kafkaTemplate = kafkaTemplate,
|
|
||||||
kafkaTopicsProperties = kafkaTopicsProperties,
|
|
||||||
)
|
|
||||||
val parsedMessage = ParsedRoutePassportMessage(
|
|
||||||
metadata = RoutePassportMessageMetadata(
|
|
||||||
traceId = "trace-1",
|
|
||||||
correlationId = "corr-1",
|
|
||||||
),
|
|
||||||
routePassportDto = RoutePassportDto(
|
|
||||||
routeId = UUID.fromString("51b07d12-1823-4bd5-b55d-de02a5304c03"),
|
|
||||||
kaShort = "S1A",
|
|
||||||
kaFull = "sentinel-1a",
|
|
||||||
routeNameFull = "ROUTE-001",
|
|
||||||
routeNameShort = "S1A-GRDH",
|
|
||||||
orbitNumber = 59796L,
|
|
||||||
orbitState = "Descending",
|
|
||||||
intervalBegin = LocalDateTime.of(2026, 4, 1, 10, 15, 0),
|
|
||||||
intervalEnd = LocalDateTime.of(2026, 4, 1, 10, 16, 30),
|
|
||||||
rollAngle = AngleRangeDto(
|
|
||||||
min = BigDecimal("-15.5"),
|
|
||||||
max = BigDecimal("-12.0"),
|
|
||||||
),
|
|
||||||
visirAngle = AngleRangeDto(
|
|
||||||
min = BigDecimal("21.0"),
|
|
||||||
max = BigDecimal("24.5"),
|
|
||||||
),
|
|
||||||
resolutionRange = BigDecimal("10.0"),
|
|
||||||
resolutionAzimuth = BigDecimal("10.0"),
|
|
||||||
polarisation = listOf("VH", "VV"),
|
|
||||||
processingLevel = "GRD",
|
|
||||||
geometry = "POLYGON((0 0, 1 0, 1 1, 0 0))",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val sendResult = mock(SendResult::class.java) as SendResult<String, String>
|
|
||||||
|
|
||||||
doAnswer { invocation ->
|
doReturn(failedFuture)
|
||||||
val message = invocation.arguments[0] as KafkaMessage<*>
|
|
||||||
"""{"type":"${message.type.enumCast()?.name}","routeNameFull":"${(message.data as RoutePassportDto).routeNameFull}"}"""
|
|
||||||
}.`when`(objectMapper).writeValueAsString(any())
|
|
||||||
doReturn(CompletableFuture.completedFuture(sendResult))
|
|
||||||
.`when`(kafkaTemplate)
|
.`when`(kafkaTemplate)
|
||||||
.send(anyProducerRecord())
|
.send(anyProducerRecord())
|
||||||
|
|
||||||
publisherService.publish(parsedMessage)
|
val thrown = assertFailsWith<IllegalStateException> {
|
||||||
|
publisherService.publish(outbox)
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val recordCaptor = ArgumentCaptor.forClass(ProducerRecord::class.java) as ArgumentCaptor<ProducerRecord<String, String>>
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val messageCaptor = ArgumentCaptor.forClass(KafkaMessage::class.java) as ArgumentCaptor<KafkaMessage<RoutePassportDto>>
|
|
||||||
|
|
||||||
verify(objectMapper, times(2)).writeValueAsString(messageCaptor.capture())
|
|
||||||
verify(kafkaTemplate, times(2)).send(captureProducerRecord(recordCaptor))
|
|
||||||
|
|
||||||
kotlin.test.assertEquals(
|
|
||||||
listOf(PcpKafkaEvent.ModeStatusChangedEvent, PcpKafkaEvent.RouteGeoRefEvent),
|
|
||||||
messageCaptor.allValues.map { it.type },
|
|
||||||
)
|
|
||||||
messageCaptor.allValues.forEach { message ->
|
|
||||||
kotlin.test.assertEquals("trace-1", message.traceId)
|
|
||||||
kotlin.test.assertEquals("corr-1", message.correlationId)
|
|
||||||
kotlin.test.assertEquals("pcp", message.group)
|
|
||||||
kotlin.test.assertEquals("route-processing", message.source)
|
|
||||||
kotlin.test.assertEquals("application/json", message.dataContentType)
|
|
||||||
kotlin.test.assertEquals("1.0", message.specVersion)
|
|
||||||
kotlin.test.assertEquals(parsedMessage.routePassportDto, message.data)
|
|
||||||
kotlin.test.assertTrue(message.id.isNotBlank())
|
|
||||||
kotlin.test.assertNotNull(message.time)
|
|
||||||
}
|
}
|
||||||
kotlin.test.assertEquals(
|
|
||||||
listOf("pcp.route.in.v1", "pcp.route.georeference.v1"),
|
assertSame(failure, thrown)
|
||||||
recordCaptor.allValues.map { it.topic() },
|
verify(kafkaTemplate, times(1)).send(anyProducerRecord())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `payload JsonNode is serialized as JSON object instead of escaped JSON string`() {
|
||||||
|
val kafkaTemplate = kafkaTemplate()
|
||||||
|
val publisherService = publisherService(kafkaTemplate)
|
||||||
|
val outbox = outbox(
|
||||||
|
payload = """{"type":"ModeStatusChangedEvent","data":{"routeId":"51b07d12-1823-4bd5-b55d-de02a5304c03"}}""",
|
||||||
)
|
)
|
||||||
kotlin.test.assertEquals(
|
|
||||||
listOf("trace-1", "trace-1"),
|
doAnswer { invocation ->
|
||||||
recordCaptor.allValues.map { it.key() },
|
@Suppress("UNCHECKED_CAST")
|
||||||
)
|
val record = invocation.arguments[0] as ProducerRecord<String, String>
|
||||||
kotlin.test.assertEquals(
|
CompletableFuture.completedFuture(sendResult(record))
|
||||||
listOf(
|
}.`when`(kafkaTemplate).send(anyProducerRecord())
|
||||||
"""{"type":"ModeStatusChangedEvent","routeNameFull":"ROUTE-001"}""",
|
|
||||||
"""{"type":"RouteGeoRefEvent","routeNameFull":"ROUTE-001"}""",
|
publisherService.publish(outbox)
|
||||||
),
|
|
||||||
recordCaptor.allValues.map { it.value() },
|
val sentPayload = captureSingleRecord(kafkaTemplate).value()
|
||||||
)
|
assertFalse(sentPayload.startsWith("\""))
|
||||||
kotlin.test.assertEquals(
|
assertTrue(objectMapper.readTree(sentPayload).isObject)
|
||||||
listOf("ModeStatusChangedEvent", "RouteGeoRefEvent"),
|
assertEquals(
|
||||||
recordCaptor.allValues.map {
|
PcpKafkaEvent.ModeStatusChangedEvent.name,
|
||||||
it.headers().lastHeader("type").value().toString(StandardCharsets.UTF_8)
|
objectMapper.readTree(sentPayload).path("type").stringValue(),
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun publisherService(
|
||||||
|
kafkaTemplate: KafkaTemplate<String, String>,
|
||||||
|
): RoutePassportKafkaPublisherService =
|
||||||
|
RoutePassportKafkaPublisherService(
|
||||||
|
objectMapper = objectMapper,
|
||||||
|
kafkaTemplate = kafkaTemplate,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun kafkaTemplate(): KafkaTemplate<String, String> =
|
||||||
|
mock(KafkaTemplate::class.java) as KafkaTemplate<String, String>
|
||||||
|
|
||||||
|
private fun captureSingleRecord(
|
||||||
|
kafkaTemplate: KafkaTemplate<String, String>,
|
||||||
|
): ProducerRecord<String, String> {
|
||||||
|
val captor = producerRecordCaptor()
|
||||||
|
verify(kafkaTemplate, times(1)).send(captor.capture())
|
||||||
|
return captor.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendResult(record: ProducerRecord<String, String>): SendResult<String, String> =
|
||||||
|
SendResult(
|
||||||
|
record,
|
||||||
|
RecordMetadata(
|
||||||
|
TopicPartition(record.topic(), 2),
|
||||||
|
42L,
|
||||||
|
0,
|
||||||
|
0L,
|
||||||
|
record.key()?.length ?: -1,
|
||||||
|
record.value()?.length ?: -1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun outbox(
|
||||||
|
payload: String = """{"type":"ModeStatusChangedEvent","data":{"routeNameFull":"ROUTE-001"}}""",
|
||||||
|
): RoutePassportOutboxEntity {
|
||||||
|
val routeId = UUID.fromString("51b07d12-1823-4bd5-b55d-de02a5304c03")
|
||||||
|
val now = LocalDateTime.of(2026, 4, 1, 10, 15, 0)
|
||||||
|
|
||||||
|
return RoutePassportOutboxEntity(
|
||||||
|
id = UUID.fromString("3ed5e75c-a5c0-4d63-88be-a701b5222b62"),
|
||||||
|
routeId = routeId,
|
||||||
|
eventType = PcpKafkaEvent.ModeStatusChangedEvent.name,
|
||||||
|
topic = "pcp.route.in.v1",
|
||||||
|
messageKey = routeId.toString(),
|
||||||
|
payload = objectMapper.readTree(payload),
|
||||||
|
headers = null,
|
||||||
|
status = RoutePassportOutboxStatus.PENDING,
|
||||||
|
attempts = 0,
|
||||||
|
maxAttempts = 10,
|
||||||
|
nextAttemptAt = now,
|
||||||
|
createdAt = now,
|
||||||
|
updatedAt = now,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun producerRecordCaptor(): ArgumentCaptor<ProducerRecord<String, String>> =
|
||||||
|
ArgumentCaptor.forClass(ProducerRecord::class.java) as ArgumentCaptor<ProducerRecord<String, String>>
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun anyProducerRecord(): ProducerRecord<String, String> =
|
||||||
|
(any(ProducerRecord::class.java) as ProducerRecord<String, String>?)
|
||||||
|
?: ProducerRecord("stub-topic", "stub-key", "stub-payload")
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import org.gradle.kotlin.dsl.maven
|
import org.gradle.kotlin.dsl.maven
|
||||||
|
|
||||||
rootProject.name = "pcp"
|
rootProject.name = "observatio-terrae"
|
||||||
rootProject.buildFileName = "build.gradle.kts"
|
rootProject.buildFileName = "build.gradle.kts"
|
||||||
|
|
||||||
include(":config-repo")
|
include(":config-repo")
|
||||||
|
|||||||
Reference in New Issue
Block a user