This commit is contained in:
Дмитрий Соловьев
2026-05-25 14:23:52 +03:00
parent b3a6012ebb
commit d48ddd2657
1066 changed files with 104601 additions and 3 deletions
@@ -0,0 +1,190 @@
package space.nstart.pcp.routepassportconsumer.config
import org.apache.kafka.clients.consumer.Consumer
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.kafka.clients.producer.ProducerRecord
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.springframework.boot.autoconfigure.AutoConfigurations
import org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.test.context.runner.ApplicationContextRunner
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer
import org.springframework.kafka.listener.DefaultErrorHandler
import org.springframework.kafka.listener.MessageListenerContainer
import org.springframework.kafka.support.KafkaHeaders
import org.springframework.kafka.support.SendResult
import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException
import java.nio.charset.StandardCharsets
import java.util.concurrent.CompletableFuture
class KafkaConsumerConfigTest {
private val contextRunner = ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(ConfigurationPropertiesAutoConfiguration::class.java))
.withUserConfiguration(TestPropertiesConfiguration::class.java, KafkaConsumerConfig::class.java)
.withPropertyValues(
"spring.kafka.bootstrap-servers=localhost:9092",
"spring.kafka.consumer.group-id=pcp-route-processing-service",
"spring.kafka.consumer.auto-offset-reset=earliest",
"app.kafka.topics.input=$INPUT_TOPIC",
"app.kafka.topics.input-dlq=$INPUT_DLQ_TOPIC",
"app.kafka.topics.processed-route=pcp.route.in.v1",
"app.kafka.topics.route-georeference=pcp.route.georeference.v1",
"app.route-passport.consumer.retry.max-attempts=3",
"app.route-passport.consumer.retry.backoff-ms=0",
"app.route-passport.consumer.retry.max-backoff-ms=0",
)
@Test
fun `should create error handler and recoverer beans`() {
contextRunner.run { context ->
assertThat(context).hasSingleBean(DefaultErrorHandler::class.java)
assertThat(context).hasSingleBean(DeadLetterPublishingRecoverer::class.java)
val retryProperties = context.getBean(RoutePassportConsumerRetryProperties::class.java)
assertThat(retryProperties.maxAttempts).isEqualTo(3)
assertThat(retryProperties.backoffMs).isZero()
assertThat(retryProperties.maxBackoffMs).isZero()
}
}
@Test
fun `dead letter recoverer should publish original string record to configured input dlq topic`() {
contextRunner.run { context ->
val kafkaTemplate = context.kafkaTemplate()
val recoverer = context.getBean(DeadLetterPublishingRecoverer::class.java)
val record = consumerRecord()
recoverer.accept(record, RoutePassportContractException("payload contract is invalid"))
val publishedRecord = capturePublishedRecord(kafkaTemplate)
assertThat(publishedRecord.topic()).isEqualTo(INPUT_DLQ_TOPIC)
assertThat(publishedRecord.partition()).isEqualTo(record.partition())
assertThat(publishedRecord.key()).isEqualTo(record.key())
assertThat(publishedRecord.value()).isEqualTo(record.value())
assertThat(publishedRecord.headers().lastStringHeader("pcp-service-name"))
.isEqualTo("pcp-route-processing-service")
assertThat(publishedRecord.headers().lastStringHeader("pcp-exception-class"))
.isEqualTo(RoutePassportContractException::class.java.name)
assertThat(publishedRecord.headers().lastStringHeader("pcp-exception-message"))
.isEqualTo("payload contract is invalid")
assertThat(publishedRecord.headers().lastStringHeader("pcp-original-topic")).isEqualTo(INPUT_TOPIC)
assertThat(publishedRecord.headers().lastStringHeader("pcp-original-partition")).isEqualTo("3")
assertThat(publishedRecord.headers().lastStringHeader("pcp-original-offset")).isEqualTo("42")
assertThat(publishedRecord.headers().lastStringHeader(KafkaHeaders.DLT_ORIGINAL_TOPIC))
.isEqualTo(INPUT_TOPIC)
assertThat(publishedRecord.headers().lastStringHeader(KafkaHeaders.DLT_EXCEPTION_FQCN))
.isEqualTo(RoutePassportContractException::class.java.name)
}
}
@Test
fun `route passport contract exception should be recovered without retry`() {
contextRunner.run { context ->
val kafkaTemplate = context.kafkaTemplate()
val errorHandler = context.getBean(DefaultErrorHandler::class.java)
val handled = errorHandler.handleOne(
RoutePassportContractException("payload contract is invalid"),
consumerRecord(),
mockConsumer(),
mock(MessageListenerContainer::class.java),
)
assertThat(handled).isTrue()
verify(kafkaTemplate, times(1)).send(anyProducerRecord())
}
}
@Test
fun `runtime exception should be retried with bounded policy before dlq recovery`() {
contextRunner.run { context ->
val kafkaTemplate = context.kafkaTemplate()
val errorHandler = context.getBean(DefaultErrorHandler::class.java)
val record = consumerRecord()
val consumer = mockConsumer()
val container = mock(MessageListenerContainer::class.java)
val exception = IllegalStateException("database is temporarily unavailable")
assertThat(errorHandler.handleOne(exception, record, consumer, container)).isFalse()
assertThat(errorHandler.handleOne(exception, record, consumer, container)).isFalse()
verify(kafkaTemplate, never()).send(anyProducerRecord())
assertThat(errorHandler.handleOne(exception, record, consumer, container)).isTrue()
verify(kafkaTemplate, times(1)).send(anyProducerRecord())
}
}
private fun consumerRecord(): ConsumerRecord<String, String> =
ConsumerRecord(
INPUT_TOPIC,
3,
42L,
"route-key",
"""{"id":"route-1"}""",
)
private fun capturePublishedRecord(
kafkaTemplate: KafkaTemplate<String, String>,
): ProducerRecord<String, String> {
val captor = producerRecordCaptor()
verify(kafkaTemplate, times(1)).send(captor.capture())
return captor.value
}
@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 mockConsumer(): Consumer<String, String> =
mock(Consumer::class.java) as Consumer<String, String>
private fun org.apache.kafka.common.header.Headers.lastStringHeader(name: String): String? =
lastHeader(name)?.value()?.toString(StandardCharsets.UTF_8)
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(
KafkaTopicsProperties::class,
RoutePassportConsumerRetryProperties::class,
)
private class TestPropertiesConfiguration {
@Bean
@Suppress("UNCHECKED_CAST")
fun kafkaTemplate(): KafkaTemplate<String, String> {
val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate<String, String>
val sendResult = mock(SendResult::class.java) as SendResult<String, String>
`when`(kafkaTemplate.send(anyProducerRecord()))
.thenReturn(CompletableFuture.completedFuture(sendResult))
return kafkaTemplate
}
}
private companion object {
const val INPUT_TOPIC = "pcp.request.survey-georeference.v1"
const val INPUT_DLQ_TOPIC = "pcp.request.survey-georeference-dlq.v1"
@Suppress("UNCHECKED_CAST")
fun anyProducerRecord(): ProducerRecord<String, String> =
(any(ProducerRecord::class.java) as ProducerRecord<String, String>?)
?: ProducerRecord("stub-topic", "stub-key", "stub-payload")
@Suppress("UNCHECKED_CAST")
fun org.springframework.context.ApplicationContext.kafkaTemplate(): KafkaTemplate<String, String> =
getBean(KafkaTemplate::class.java) as KafkaTemplate<String, String>
}
}
@@ -0,0 +1,63 @@
package space.nstart.pcp.routepassportconsumer.controller
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito.mock
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.post
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties
import java.nio.charset.StandardCharsets
class RouteProcessingTestControllerTest {
@Suppress("UNCHECKED_CAST")
private val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate<String, String>
private val kafkaTopicsProperties = KafkaTopicsProperties(
input = "pcp.request.survey-georeference.v1",
processedRoute = "pcp.route.in.v1",
routeGeoreference = "pcp.route.georeference.v1",
)
private val mockMvc: MockMvc = MockMvcBuilders
.standaloneSetup(RouteProcessingTestController(kafkaTemplate, kafkaTopicsProperties))
.build()
@Test
fun `GET test endpoint returns service status`() {
mockMvc.get("/api/route-processing/test")
.andExpect {
status { isOk() }
jsonPath("$.status") { value("ok") }
jsonPath("$.service") { value("pcp-route-processing-service") }
}
}
@Test
fun `POST send endpoint publishes test passport message to input topic`() {
mockMvc.post("/api/route-processing/test/send-passport-message")
.andExpect {
status { isOk() }
jsonPath("$.status") { value("sent") }
jsonPath("$.topic") { value("pcp.request.survey-georeference.v1") }
jsonPath("$.resource") { value("classpath:test-passport-message.json") }
}
val payloadCaptor = ArgumentCaptor.forClass(String::class.java)
verify(kafkaTemplate, times(1)).send(
org.mockito.Mockito.eq("pcp.request.survey-georeference.v1"),
payloadCaptor.capture(),
)
val expectedPayload = this::class.java.classLoader
.getResourceAsStream("test-passport-message.json")
?.readAllBytes()
?.toString(StandardCharsets.UTF_8)
?: error("Не найден файл test-passport-message.json в test resources")
kotlin.test.assertEquals(expectedPayload, payloadCaptor.value)
}
}
@@ -0,0 +1,176 @@
package space.nstart.pcp.routepassportconsumer.listener
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import org.mockito.Mockito.doThrow
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoInteractions
import org.mockito.Mockito.`when`
import org.springframework.kafka.support.Acknowledgment
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.routepassportconsumer.config.KafkaTopicsProperties
import space.nstart.pcp.routepassportconsumer.service.RoutePassportIngestionService
import space.nstart.pcp.routepassportconsumer.service.RoutePassportJsonParserService
import space.nstart.pcp.routepassportconsumer.service.RoutePassportSourceRecordMetadata
import java.math.BigDecimal
import java.time.LocalDateTime
import java.util.UUID
class RoutePassportMessageListenerTest {
@Test
fun `should parse incoming record, ingest it and acknowledge offset`() {
val parserService = mock(RoutePassportJsonParserService::class.java)
val ingestionService = mock(RoutePassportIngestionService::class.java)
val acknowledgment = mock(Acknowledgment::class.java)
val listener = RoutePassportMessageListener(
routePassportJsonParserService = parserService,
routePassportIngestionService = ingestionService,
kafkaTopicsProperties = kafkaTopicsProperties(),
)
val rawPayload = """{"id":"route-1"}"""
val record = consumerRecord(rawPayload)
val parsedMessage = parsedMessage()
val sourceRecord = sourceRecordMetadata(record)
`when`(parserService.parse(rawPayload)).thenReturn(parsedMessage)
listener.listen(record, acknowledgment)
verify(parserService).parse(rawPayload)
verify(ingestionService).ingest(
parsedMessage = parsedMessage,
rawPayload = rawPayload,
sourceRecord = sourceRecord,
)
verify(acknowledgment, times(1)).acknowledge()
}
@Test
fun `should not ingest or acknowledge when parser throws exception`() {
val parserService = mock(RoutePassportJsonParserService::class.java)
val ingestionService = mock(RoutePassportIngestionService::class.java)
val acknowledgment = mock(Acknowledgment::class.java)
val listener = RoutePassportMessageListener(
routePassportJsonParserService = parserService,
routePassportIngestionService = ingestionService,
kafkaTopicsProperties = kafkaTopicsProperties(),
)
val rawPayload = """{"id":"route-1"}"""
val record = consumerRecord(rawPayload)
val exception = IllegalArgumentException("payload is invalid")
`when`(parserService.parse(rawPayload)).thenThrow(exception)
assertThrows(IllegalArgumentException::class.java) {
listener.listen(record, acknowledgment)
}
verify(parserService).parse(rawPayload)
verifyNoInteractions(ingestionService)
verify(acknowledgment, never()).acknowledge()
}
@Test
fun `should not acknowledge when ingestion throws exception`() {
val parserService = mock(RoutePassportJsonParserService::class.java)
val ingestionService = mock(RoutePassportIngestionService::class.java)
val acknowledgment = mock(Acknowledgment::class.java)
val listener = RoutePassportMessageListener(
routePassportJsonParserService = parserService,
routePassportIngestionService = ingestionService,
kafkaTopicsProperties = kafkaTopicsProperties(),
)
val rawPayload = """{"id":"route-1"}"""
val record = consumerRecord(rawPayload)
val parsedMessage = parsedMessage()
val sourceRecord = sourceRecordMetadata(record)
val exception = IllegalStateException("database is unavailable")
`when`(parserService.parse(rawPayload)).thenReturn(parsedMessage)
doThrow(exception).`when`(ingestionService).ingest(
parsedMessage = parsedMessage,
rawPayload = rawPayload,
sourceRecord = sourceRecord,
)
assertThrows(IllegalStateException::class.java) {
listener.listen(record, acknowledgment)
}
verify(parserService).parse(rawPayload)
verify(ingestionService).ingest(
parsedMessage = parsedMessage,
rawPayload = rawPayload,
sourceRecord = sourceRecord,
)
verify(acknowledgment, never()).acknowledge()
}
private fun kafkaTopicsProperties(): KafkaTopicsProperties =
KafkaTopicsProperties(
input = INPUT_TOPIC,
processedRoute = "pcp.route.in.v1",
routeGeoreference = "pcp.route.georeference.v1",
)
private fun consumerRecord(rawPayload: String): ConsumerRecord<String, String> =
ConsumerRecord(
INPUT_TOPIC,
3,
42L,
"route-key",
rawPayload,
)
private fun sourceRecordMetadata(record: ConsumerRecord<String, String>): RoutePassportSourceRecordMetadata =
RoutePassportSourceRecordMetadata(
topic = record.topic(),
partition = record.partition(),
offset = record.offset(),
key = record.key(),
)
private fun parsedMessage(): ParsedRoutePassportMessage =
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))",
),
)
private companion object {
const val INPUT_TOPIC = "pcp.request.survey-georeference.v1"
}
}
@@ -0,0 +1,87 @@
package space.nstart.pcp.routepassportconsumer.service
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import space.nstart.pcp.pcp_types_lib.dto.routes.RawGeoJsonGeometryDto
import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException
import tools.jackson.databind.ObjectMapper
import tools.jackson.databind.json.JsonMapper
import tools.jackson.module.kotlin.KotlinModule
class RouteGeometryConverterTest {
private val objectMapper: ObjectMapper = JsonMapper.builder()
.addModule(KotlinModule.Builder().build())
.build()
private val converter = RouteGeometryConverter()
@Test
fun `should convert valid polygon to wkt`() {
val geometry = geometry(
type = "Polygon",
coordinates = """[[[0, 0], [1, 0], [1, 1], [0, 0]]]""",
)
val wkt = converter.convert(geometry)
assertTrue(wkt.startsWith("POLYGON"))
}
@Test
fun `should convert valid multipolygon to wkt`() {
val geometry = geometry(
type = "MultiPolygon",
coordinates = """[[[[0, 0], [1, 0], [1, 1], [0, 0]]]]""",
)
val wkt = converter.convert(geometry)
assertTrue(wkt.startsWith("MULTIPOLYGON"))
}
@Test
fun `should reject unsupported geometry type`() {
val geometry = geometry(
type = "Point",
coordinates = """[0, 0]""",
)
assertThrows(RoutePassportContractException::class.java) {
converter.convert(geometry)
}
}
@Test
fun `should reject malformed coordinates`() {
val geometry = geometry(
type = "Polygon",
coordinates = """[[[0, 0], [1, 0], [1, 1]]]""",
)
assertThrows(RoutePassportContractException::class.java) {
converter.convert(geometry)
}
}
@Test
fun `should reject non numeric coordinate values`() {
val geometry = geometry(
type = "Polygon",
coordinates = """[[[0, 0], ["bad", 0], [1, 1], [0, 0]]]""",
)
assertThrows(RoutePassportContractException::class.java) {
converter.convert(geometry)
}
}
private fun geometry(
type: String,
coordinates: String,
): RawGeoJsonGeometryDto =
RawGeoJsonGeometryDto(
type = type,
coordinates = objectMapper.readTree(coordinates),
)
}
@@ -0,0 +1,302 @@
package space.nstart.pcp.routepassportconsumer.service
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
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.PcpKafkaEvent
import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportEntity
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxEntity
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxStatus
import space.nstart.pcp.routepassportconsumer.repository.RoutePassportOutboxRepository
import space.nstart.pcp.routepassportconsumer.repository.RoutePassportRepository
import tools.jackson.databind.ObjectMapper
import tools.jackson.databind.json.JsonMapper
import tools.jackson.module.kotlin.KotlinModule
import java.math.BigDecimal
import java.time.LocalDateTime
import java.util.Optional
import java.util.UUID
class RoutePassportIngestionServiceTest {
private val objectMapper: ObjectMapper = JsonMapper.builder()
.addModule(KotlinModule.Builder().build())
.build()
private val kafkaTopicsProperties = KafkaTopicsProperties(
input = "pcp.request.survey-georeference.v1",
processedRoute = "pcp.route.in.v1",
routeGeoreference = "pcp.route.georeference.v1",
)
@Test
fun `first ingest creates route passport and two pending outbox events`() {
val fixture = fixture()
val routePassportRepository = mock(RoutePassportRepository::class.java)
val routePassportOutboxRepository = mock(RoutePassportOutboxRepository::class.java)
val savedRoutes = mutableListOf<RoutePassportEntity>()
val savedOutbox = mutableListOf<RoutePassportOutboxEntity>()
val service = service(routePassportRepository, routePassportOutboxRepository)
doReturn(Optional.empty<RoutePassportEntity>())
.`when`(routePassportRepository)
.findById(fixture.routeId)
captureRouteSaves(routePassportRepository, savedRoutes)
captureOutboxSaves(routePassportOutboxRepository, savedOutbox)
service.ingest(fixture.parsedMessage, fixture.rawPayload, fixture.sourceRecord)
assertEquals(1, savedRoutes.size)
assertEquals(fixture.routeId, savedRoutes.single().routeId)
assertEquals("ROUTE-001", savedRoutes.single().routeNameFull)
assertTrue(savedRoutes.single().polarisation.isArray)
assertEquals("""["VH","VV"]""", savedRoutes.single().polarisation.toString())
assertTrue(savedRoutes.single().rawPayload?.isObject == true)
assertEquals("PROCESSED", savedRoutes.single().routeStatus)
assertEquals(2, savedOutbox.size)
assertEquals(
listOf(PcpKafkaEvent.ModeStatusChangedEvent.name, PcpKafkaEvent.RouteGeoRefEvent.name),
savedOutbox.map { it.eventType },
)
assertEquals(
listOf("pcp.route.in.v1", "pcp.route.georeference.v1"),
savedOutbox.map { it.topic },
)
savedOutbox.forEach { outbox ->
assertEquals(fixture.routeId, outbox.routeId)
assertEquals(fixture.routeId.toString(), outbox.messageKey)
assertEquals(RoutePassportOutboxStatus.PENDING, outbox.status)
assertEquals(0, outbox.attempts)
assertEquals(10, outbox.maxAttempts)
assertNotNull(outbox.nextAttemptAt)
}
}
@Test
fun `duplicate ingest updates route passport without creating duplicate outbox rows`() {
val fixture = fixture()
val existingEntity = existingRouteEntity(fixture.routeId).apply {
createdAt = LocalDateTime.of(2026, 1, 1, 1, 0)
updatedAt = LocalDateTime.of(2026, 1, 1, 1, 0)
routeNameFull = "OLD-ROUTE"
}
val routePassportRepository = mock(RoutePassportRepository::class.java)
val routePassportOutboxRepository = mock(RoutePassportOutboxRepository::class.java)
val savedRoutes = mutableListOf<RoutePassportEntity>()
val service = service(routePassportRepository, routePassportOutboxRepository)
doReturn(Optional.of(existingEntity))
.`when`(routePassportRepository)
.findById(fixture.routeId)
doReturn(true)
.`when`(routePassportOutboxRepository)
.existsByRouteIdAndEventType(fixture.routeId, PcpKafkaEvent.ModeStatusChangedEvent.name)
doReturn(true)
.`when`(routePassportOutboxRepository)
.existsByRouteIdAndEventType(fixture.routeId, PcpKafkaEvent.RouteGeoRefEvent.name)
captureRouteSaves(routePassportRepository, savedRoutes)
service.ingest(fixture.parsedMessage, fixture.rawPayload, fixture.sourceRecord)
assertEquals(1, savedRoutes.size)
assertEquals("ROUTE-001", existingEntity.routeNameFull)
assertEquals(LocalDateTime.of(2026, 1, 1, 1, 0), existingEntity.createdAt)
assertTrue(existingEntity.updatedAt.isAfter(existingEntity.createdAt))
verify(routePassportOutboxRepository, never()).save(anyRoutePassportOutboxEntity())
}
@Test
fun `ingest stores source record metadata in route passport`() {
val fixture = fixture()
val routePassportRepository = mock(RoutePassportRepository::class.java)
val routePassportOutboxRepository = mock(RoutePassportOutboxRepository::class.java)
val savedRoutes = mutableListOf<RoutePassportEntity>()
val service = service(routePassportRepository, routePassportOutboxRepository)
doReturn(Optional.empty<RoutePassportEntity>())
.`when`(routePassportRepository)
.findById(fixture.routeId)
captureRouteSaves(routePassportRepository, savedRoutes)
service.ingest(fixture.parsedMessage, fixture.rawPayload, fixture.sourceRecord)
val routeEntity = savedRoutes.single()
assertEquals("pcp.request.survey-georeference.v1", routeEntity.sourceTopic)
assertEquals(3, routeEntity.sourcePartition)
assertEquals(42L, routeEntity.sourceOffset)
assertEquals("source-key-1", routeEntity.sourceKey)
assertEquals(objectMapper.readTree(fixture.rawPayload), routeEntity.rawPayload)
assertEquals("trace-1", routeEntity.traceId)
assertEquals("corr-1", routeEntity.correlationId)
}
@Test
fun `outbox payload keeps downstream kafka message contract`() {
val fixture = fixture()
val routePassportRepository = mock(RoutePassportRepository::class.java)
val routePassportOutboxRepository = mock(RoutePassportOutboxRepository::class.java)
val savedOutbox = mutableListOf<RoutePassportOutboxEntity>()
val service = service(routePassportRepository, routePassportOutboxRepository)
doReturn(Optional.empty<RoutePassportEntity>())
.`when`(routePassportRepository)
.findById(fixture.routeId)
captureRouteSaves(routePassportRepository, mutableListOf())
captureOutboxSaves(routePassportOutboxRepository, savedOutbox)
service.ingest(fixture.parsedMessage, fixture.rawPayload, fixture.sourceRecord)
val modeStatusPayload = savedOutbox.first().payload
assertTrue(modeStatusPayload.isObject)
assertEquals(PcpKafkaEvent.ModeStatusChangedEvent.name, modeStatusPayload.path("type").stringValue())
assertEquals("trace-1", modeStatusPayload.path("traceId").stringValue())
assertEquals("corr-1", modeStatusPayload.path("correlationId").stringValue())
assertEquals("route-processing", modeStatusPayload.path("source").stringValue())
assertEquals("pcp", modeStatusPayload.path("group").stringValue())
assertTrue(modeStatusPayload.path("data").isObject)
assertEquals(fixture.routeId.toString(), modeStatusPayload.path("data").path("routeId").stringValue())
assertEquals("ROUTE-001", modeStatusPayload.path("data").path("routeNameFull").stringValue())
}
private fun service(
routePassportRepository: RoutePassportRepository,
routePassportOutboxRepository: RoutePassportOutboxRepository,
): RoutePassportIngestionService =
RoutePassportIngestionService(
routePassportRepository = routePassportRepository,
routePassportOutboxRepository = routePassportOutboxRepository,
objectMapper = objectMapper,
kafkaTopicsProperties = kafkaTopicsProperties,
)
private fun captureRouteSaves(
routePassportRepository: RoutePassportRepository,
savedRoutes: MutableList<RoutePassportEntity>,
) {
doAnswer { invocation ->
val entity = invocation.arguments[0] as RoutePassportEntity
savedRoutes += entity
entity
}.`when`(routePassportRepository).save(anyRoutePassportEntity())
}
private fun captureOutboxSaves(
routePassportOutboxRepository: RoutePassportOutboxRepository,
savedOutbox: MutableList<RoutePassportOutboxEntity>,
) {
doAnswer { invocation ->
val entity = invocation.arguments[0] as RoutePassportOutboxEntity
savedOutbox += entity
entity
}.`when`(routePassportOutboxRepository).save(anyRoutePassportOutboxEntity())
}
private fun anyRoutePassportEntity(): RoutePassportEntity =
any(RoutePassportEntity::class.java) ?: existingRouteEntity(UUID.randomUUID())
private fun anyRoutePassportOutboxEntity(): RoutePassportOutboxEntity =
any(RoutePassportOutboxEntity::class.java) ?: RoutePassportOutboxEntity(
id = UUID.randomUUID(),
routeId = UUID.randomUUID(),
eventType = PcpKafkaEvent.ModeStatusChangedEvent.name,
topic = "topic",
messageKey = "key",
payload = objectMapper.readTree("{}"),
status = RoutePassportOutboxStatus.PENDING,
attempts = 0,
maxAttempts = 10,
nextAttemptAt = LocalDateTime.now(),
createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now(),
)
private fun fixture(): IngestionFixture {
val routeId = UUID.fromString("51b07d12-1823-4bd5-b55d-de02a5304c03")
val routePassportDto = RoutePassportDto(
routeId = routeId,
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))",
)
return IngestionFixture(
routeId = routeId,
parsedMessage = ParsedRoutePassportMessage(
metadata = RoutePassportMessageMetadata(
traceId = "trace-1",
correlationId = "corr-1",
),
routePassportDto = routePassportDto,
),
rawPayload = """{"data":{"id":"ROUTE-001"}}""",
sourceRecord = RoutePassportSourceRecordMetadata(
topic = "pcp.request.survey-georeference.v1",
partition = 3,
offset = 42L,
key = "source-key-1",
),
)
}
private fun existingRouteEntity(routeId: UUID): RoutePassportEntity =
RoutePassportEntity(
routeId = routeId,
routeNameFull = "ROUTE-OLD",
routeNameShort = "OLD",
kaShort = "S1A",
kaFull = "sentinel-1a",
orbitNumber = 1L,
orbitState = "Descending",
intervalBegin = LocalDateTime.of(2026, 1, 1, 0, 0),
intervalEnd = LocalDateTime.of(2026, 1, 1, 0, 1),
rollAngleMin = BigDecimal.ZERO,
rollAngleMax = BigDecimal.ONE,
visirAngleMin = BigDecimal.ZERO,
visirAngleMax = BigDecimal.ONE,
resolutionRange = BigDecimal.TEN,
resolutionAzimuth = BigDecimal.TEN,
polarisation = objectMapper.readTree("""["VH"]"""),
processingLevel = "GRD",
geometryWkt = "POLYGON((0 0, 1 0, 1 1, 0 0))",
routeStatus = "PROCESSED",
createdAt = LocalDateTime.of(2026, 1, 1, 0, 0),
updatedAt = LocalDateTime.of(2026, 1, 1, 0, 0),
)
private data class IngestionFixture(
val routeId: UUID,
val parsedMessage: ParsedRoutePassportMessage,
val rawPayload: String,
val sourceRecord: RoutePassportSourceRecordMetadata,
)
}
@@ -0,0 +1,198 @@
package space.nstart.pcp.routepassportconsumer.service
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import space.nstart.pcp.pcp_types_lib.dto.routes.RawGeoJsonGeometryDto
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException
import java.util.UUID
import tools.jackson.databind.ObjectMapper
import tools.jackson.databind.json.JsonMapper
import tools.jackson.module.kotlin.KotlinModule
import java.nio.charset.StandardCharsets
/**
* Тест сервиса парсинга JSON паспорта маршрута.
*/
class RoutePassportJsonParserServiceTest {
private val objectMapper: ObjectMapper = JsonMapper.builder()
.addModule(KotlinModule.Builder().build())
.build()
@Test
fun `should parse envelope passport json into dto`() {
val parserService = RoutePassportJsonParserService(objectMapper, RouteGeometryConverter())
val json: String = this::class.java.classLoader
.getResourceAsStream("test-passport-message.json")
?.readAllBytes()
?.toString(StandardCharsets.UTF_8)
?: error("Не найден файл test-passport-message.json в classpath")
val parsedMessage = parserService.parse(json)
val dto = parsedMessage.routePassportDto
assertEquals("c0dd08ea-dd64-4633-8845-d1b163bdf506", parsedMessage.metadata.traceId)
assertEquals("db605d5f-77e9-45b4-82cc-709837306257", parsedMessage.metadata.correlationId)
assertEquals(UUID.fromString("51b07d12-1823-4bd5-b55d-de02a5304c03"), dto.routeId)
assertEquals("S1A", dto.kaShort)
assertEquals("sentinel-1a", dto.kaFull)
assertEquals("S1A-GRDH", dto.routeNameShort)
assertEquals(
"S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE",
dto.routeNameFull,
)
assertEquals(59796L, dto.orbitNumber)
assertEquals("Descending", dto.orbitState)
assertEquals("GRD", dto.processingLevel)
assertEquals(SurveyModeStatus.PROCESSED, dto.routeStatus)
assertEquals(listOf("VH", "VV"), dto.polarisation)
assertEquals(
"POLYGON((67.675827 38.114815, 64.779434 38.514816, 65.094017 40.015545, 68.05294 39.616795, 67.675827 38.114815))",
dto.geometry,
)
}
@Test
fun `should generate route id when raw payload does not contain it`() {
val parserService = RoutePassportJsonParserService(objectMapper, RouteGeometryConverter())
val json = """
{
"traceid": "trace-1",
"correlationid": "corr-1",
"data": {
"id": "ROUTE-001",
"collection": "S1A-GRDH",
"geometry": {
"type": "Polygon",
"coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]
},
"properties": {
"platform": "sentinel-1a",
"missionId": "S1A",
"route:orbit_number": "59796",
"sat:orbit_state": "Descending",
"intervalBegin": "2025-06-25T01:38:54.481190",
"intervalEnd": "2025-06-25T01:39:19.479675",
"rollAngle": [-5.0, 12.0],
"visirAngle": [30.0, 46.0],
"resolutionRange": "1.000000e+01",
"resolutionAzimuth": "1.000000e+01",
"polarisation": ["VH", "VV"],
"processing:level": "GRD"
}
}
}
""".trimIndent()
val parsedMessage = parserService.parse(json)
assertEquals(
UUID.nameUUIDFromBytes("ROUTE-001".toByteArray(StandardCharsets.UTF_8)),
parsedMessage.routePassportDto.routeId,
)
}
@Test
fun `should parse trace_id alias without changing correlationid`() {
val parserService = RoutePassportJsonParserService(objectMapper, RouteGeometryConverter())
val json = """
{
"trace_id": "trace-aliased",
"correlationid": "corr-aliased",
"data": {
"id": "ROUTE-001",
"collection": "S1A-GRDH",
"geometry": {
"type": "Polygon",
"coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]
},
"properties": {
"platform": "sentinel-1a",
"missionId": "S1A",
"route:orbit_number": "59796",
"sat:orbit_state": "Descending",
"intervalBegin": "2025-06-25T01:38:54.481190",
"intervalEnd": "2025-06-25T01:39:19.479675",
"rollAngle": [-5.0, 12.0],
"visirAngle": [30.0, 46.0],
"resolutionRange": "1.000000e+01",
"resolutionAzimuth": "1.000000e+01",
"polarisation": ["VH", "VV"],
"processing:level": "GRD"
}
}
}
""".trimIndent()
val parsedMessage = parserService.parse(json)
assertEquals("trace-aliased", parsedMessage.metadata.traceId)
assertEquals("corr-aliased", parsedMessage.metadata.correlationId)
}
@Test
fun `should take geometry from converter`() {
val routeGeometryConverter = StubRouteGeometryConverter()
val parserService = RoutePassportJsonParserService(objectMapper, routeGeometryConverter)
val parsedMessage = parserService.parse(minimalJson("Polygon", "[[[0, 0], [1, 0], [1, 1], [0, 0]]]"))
assertEquals("POLYGON((9 9, 10 9, 10 10, 9 9))", parsedMessage.routePassportDto.geometry)
assertEquals(1, routeGeometryConverter.callCount)
}
@Test
fun `should reject unsupported geometry type in parser path`() {
val parserService = RoutePassportJsonParserService(objectMapper, RouteGeometryConverter())
assertThrows(RoutePassportContractException::class.java) {
parserService.parse(minimalJson("Point", "[0, 0]"))
}
}
private fun minimalJson(
geometryType: String,
coordinates: String,
): String =
"""
{
"traceid": "trace-1",
"correlationid": "corr-1",
"data": {
"id": "ROUTE-001",
"collection": "S1A-GRDH",
"geometry": {
"type": "$geometryType",
"coordinates": $coordinates
},
"properties": {
"platform": "sentinel-1a",
"missionId": "S1A",
"route:orbit_number": "59796",
"sat:orbit_state": "Descending",
"intervalBegin": "2025-06-25T01:38:54.481190",
"intervalEnd": "2025-06-25T01:39:19.479675",
"rollAngle": [-5.0, 12.0],
"visirAngle": [30.0, 46.0],
"resolutionRange": "1.000000e+01",
"resolutionAzimuth": "1.000000e+01",
"polarisation": ["VH", "VV"],
"processing:level": "GRD"
}
}
}
""".trimIndent()
private class StubRouteGeometryConverter : RouteGeometryConverter() {
var callCount: Int = 0
override fun convert(geometry: RawGeoJsonGeometryDto?): String {
callCount += 1
return "POLYGON((9 9, 10 9, 10 10, 9 9))"
}
}
}
@@ -0,0 +1,144 @@
package space.nstart.pcp.routepassportconsumer.service
import org.apache.kafka.clients.producer.ProducerRecord
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.springframework.kafka.core.KafkaTemplate
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 java.math.BigDecimal
import java.nio.charset.StandardCharsets
import java.time.LocalDateTime
import java.util.UUID
import java.util.concurrent.CompletableFuture
import tools.jackson.databind.ObjectMapper
class RoutePassportKafkaPublisherServiceTest {
private fun anyProducerRecord(): ProducerRecord<String, String> {
@Suppress("UNCHECKED_CAST")
return (any(ProducerRecord::class.java) as ProducerRecord<String, String>?)
?: ProducerRecord("stub-topic", "stub-key", "stub-payload")
}
private fun captureProducerRecord(
captor: ArgumentCaptor<ProducerRecord<String, String>>,
): ProducerRecord<String, String> {
return captor.capture() ?: ProducerRecord("stub-topic", "stub-key", "stub-payload")
}
@Test
fun `should publish kafka message to both output topics with dedicated event types`() {
val objectMapper = mock(ObjectMapper::class.java)
@Suppress("UNCHECKED_CAST")
val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate<String, String>
val kafkaTopicsProperties = KafkaTopicsProperties(
input = "pcp.request.survey-georeference.v1",
processedRoute = "pcp.route.in.v1",
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 ->
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)
.send(anyProducerRecord())
publisherService.publish(parsedMessage)
@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"),
recordCaptor.allValues.map { it.topic() },
)
kotlin.test.assertEquals(
listOf("trace-1", "trace-1"),
recordCaptor.allValues.map { it.key() },
)
kotlin.test.assertEquals(
listOf(
"""{"type":"ModeStatusChangedEvent","routeNameFull":"ROUTE-001"}""",
"""{"type":"RouteGeoRefEvent","routeNameFull":"ROUTE-001"}""",
),
recordCaptor.allValues.map { it.value() },
)
kotlin.test.assertEquals(
listOf("ModeStatusChangedEvent", "RouteGeoRefEvent"),
recordCaptor.allValues.map {
it.headers().lastHeader("type").value().toString(StandardCharsets.UTF_8)
},
)
}
}
File diff suppressed because one or more lines are too long