Init
This commit is contained in:
+190
@@ -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>
|
||||
}
|
||||
}
|
||||
+63
@@ -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)
|
||||
}
|
||||
}
|
||||
+176
@@ -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"
|
||||
}
|
||||
}
|
||||
+87
@@ -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),
|
||||
)
|
||||
}
|
||||
+302
@@ -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,
|
||||
)
|
||||
}
|
||||
+198
@@ -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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
+144
@@ -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
Reference in New Issue
Block a user