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,35 @@
# route-passport-kafka-consumer
Минимальный сервис на Kotlin + Spring Boot, который:
- слушает Kafka topic;
- получает в сообщении JSON паспорта маршрута;
- парсит JSON в DTO;
- больше ничего не делает.
## Что внутри
- `RoutePassportConsumerApplication` — точка входа Spring Boot.
- `RoutePassportMessageListener` — Kafka listener.
- `RoutePassportJsonParserService` — сервис парсинга входного JSON в DTO.
- `RoutePassportDto` — итоговый DTO из `pcp-types-lib`.
- `RawPassportEnvelopeDto` и связанные `Raw*` DTO — модель входного JSON из `pcp-types-lib`.
## Как запустить
1. Поднять Kafka.
2. Указать адрес брокера в `application.yml` или через переменную окружения `KAFKA_BOOTSTRAP_SERVERS`.
3. При необходимости указать topic через `APP_KAFKA_TOPIC`.
4. Запустить приложение:
```bash
./gradlew bootRun
```
## Сообщение, которое ожидает сервис
Сервис ожидает, что value Kafka-сообщения — это JSON того же формата, что и `src/test/resources/passport_example.json`.
Поле `geometry` должно приходить строкой в формате WKT.
## Примечание
Поскольку в запросе был указан просто «слушает очередь», реализация сделана через Kafka topic как наиболее типичный вариант для стека Kotlin + Spring и Kafka-ориентированного сервиса.
@@ -0,0 +1,57 @@
group = "space.nstart.pcp"
plugins {
kotlin("jvm")
kotlin("plugin.spring")
kotlin("plugin.jpa")
id("org.springframework.boot")
id("io.spring.dependency-management")
}
version = "1.0.0"
description = "Входная точка маршрутов от портала для их последующей обработки в ПЦП"
kotlin {
jvmToolchain((property("versions.java") as String).toInt())
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.fromTarget(property("versions.java") as String))
}
}
dependencies {
implementation(project(":libs:pcp-types-lib"))
implementation("${property("dep.spring.actuator")}")
implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.flywaydb:flyway-database-postgresql")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("versions.open-api")}")
implementation("org.springframework.kafka:spring-kafka")
implementation("org.springframework.cloud:spring-cloud-starter-config")
implementation("tools.jackson.module:jackson-module-kotlin")
implementation("org.locationtech.jts:jts-core:1.19.0")
implementation("org.jetbrains.kotlin:kotlin-reflect")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.kafka:spring-kafka-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}")
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
package space.nstart.pcp.routepassportconsumer
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
import org.springframework.kafka.annotation.EnableKafka
/**
* Главный класс Spring Boot приложения.
*
* Приложение поднимает только инфраструктуру потребителя Kafka и сервис парсинга JSON.
*/
@ConfigurationPropertiesScan
@SpringBootApplication
@EnableKafka
class RoutePassportConsumerApplication
/**
* Точка входа приложения.
*
* @param args аргументы командной строки, которые передаются в Spring Boot.
*/
fun main(args: Array<String>) {
runApplication<RoutePassportConsumerApplication>(*args)
}
@@ -0,0 +1,131 @@
package space.nstart.pcp.routepassportconsumer.config
import com.fasterxml.jackson.core.JsonParseException
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.JsonMappingException
import org.apache.kafka.clients.consumer.ConsumerConfig
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.kafka.common.TopicPartition
import org.apache.kafka.common.header.Headers
import org.apache.kafka.common.header.internals.RecordHeader
import org.apache.kafka.common.header.internals.RecordHeaders
import org.apache.kafka.common.serialization.StringDeserializer
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory
import org.springframework.kafka.core.ConsumerFactory
import org.springframework.kafka.core.DefaultKafkaConsumerFactory
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.listener.ContainerProperties
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer
import org.springframework.kafka.listener.DefaultErrorHandler
import org.springframework.util.backoff.ExponentialBackOff
import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException
import tools.jackson.core.JacksonException
import java.nio.charset.StandardCharsets
@Configuration
@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false)
class KafkaConsumerConfig(
@param:Value("\${spring.kafka.bootstrap-servers}")
private val bootstrapServers: String,
@param:Value("\${spring.kafka.consumer.group-id}")
private val groupId: String,
@param:Value("\${spring.kafka.consumer.auto-offset-reset:latest}")
private val autoOffsetReset: String,
) {
@Bean
fun consumerFactory(): ConsumerFactory<String, String> =
DefaultKafkaConsumerFactory(
mutableMapOf<String, Any>(
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
ConsumerConfig.GROUP_ID_CONFIG to groupId,
ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to autoOffsetReset,
ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false,
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
),
)
@Bean
fun kafkaListenerContainerFactory(
kafkaErrorHandler: DefaultErrorHandler,
): ConcurrentKafkaListenerContainerFactory<String, String> =
ConcurrentKafkaListenerContainerFactory<String, String>().apply {
setConsumerFactory(consumerFactory())
setCommonErrorHandler(kafkaErrorHandler)
containerProperties.ackMode = ContainerProperties.AckMode.MANUAL_IMMEDIATE
}
@Bean
fun deadLetterPublishingRecoverer(
kafkaTemplate: KafkaTemplate<String, String>,
kafkaTopicsProperties: KafkaTopicsProperties,
): DeadLetterPublishingRecoverer =
DeadLetterPublishingRecoverer(kafkaTemplate) { record, _ ->
TopicPartition(kafkaTopicsProperties.inputDlq, record.partition())
}.apply {
addHeadersFunction { record, exception -> routePassportDlqHeaders(record, exception) }
}
@Bean
fun kafkaErrorHandler(
deadLetterPublishingRecoverer: DeadLetterPublishingRecoverer,
retryProperties: RoutePassportConsumerRetryProperties,
): DefaultErrorHandler =
DefaultErrorHandler(
deadLetterPublishingRecoverer,
routePassportConsumerBackOff(retryProperties),
).apply {
addNotRetryableExceptions(
RoutePassportContractException::class.java,
JacksonException::class.java,
JsonProcessingException::class.java,
JsonMappingException::class.java,
JsonParseException::class.java,
)
setCommitRecovered(true)
}
private fun routePassportConsumerBackOff(
retryProperties: RoutePassportConsumerRetryProperties,
): ExponentialBackOff =
ExponentialBackOff().apply {
initialInterval = retryProperties.backoffMs
multiplier = RETRY_BACKOFF_MULTIPLIER
maxInterval = retryProperties.maxBackoffMs
maxAttempts = (retryProperties.maxAttempts - 1).coerceAtLeast(0)
}
private fun routePassportDlqHeaders(
record: ConsumerRecord<*, *>,
exception: Exception,
): Headers =
RecordHeaders().apply {
addStringHeader(HEADER_SERVICE_NAME, SERVICE_NAME)
addStringHeader(HEADER_EXCEPTION_CLASS, exception::class.java.name)
addStringHeader(HEADER_EXCEPTION_MESSAGE, exception.message.orEmpty())
addStringHeader(HEADER_ORIGINAL_TOPIC, record.topic())
addStringHeader(HEADER_ORIGINAL_PARTITION, record.partition().toString())
addStringHeader(HEADER_ORIGINAL_OFFSET, record.offset().toString())
}
private fun Headers.addStringHeader(name: String, value: String) {
add(RecordHeader(name, value.toByteArray(StandardCharsets.UTF_8)))
}
private companion object {
const val SERVICE_NAME = "pcp-route-processing-service"
const val RETRY_BACKOFF_MULTIPLIER = 2.0
const val HEADER_SERVICE_NAME = "pcp-service-name"
const val HEADER_EXCEPTION_CLASS = "pcp-exception-class"
const val HEADER_EXCEPTION_MESSAGE = "pcp-exception-message"
const val HEADER_ORIGINAL_TOPIC = "pcp-original-topic"
const val HEADER_ORIGINAL_PARTITION = "pcp-original-partition"
const val HEADER_ORIGINAL_OFFSET = "pcp-original-offset"
}
}
@@ -0,0 +1,38 @@
package space.nstart.pcp.routepassportconsumer.config
import org.apache.kafka.clients.producer.ProducerConfig
import org.apache.kafka.common.serialization.StringSerializer
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.kafka.core.DefaultKafkaProducerFactory
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.core.ProducerFactory
/**
* Явная producer-конфигурация для route-processing-service.
*
* В этом модуле Kafka producer bean'ы не поднимались автоконфигурацией,
* поэтому template регистрируется явно.
*/
@Configuration
@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false)
class KafkaProducerConfig(
@param:Value("\${spring.kafka.bootstrap-servers}")
private val bootstrapServers: String,
) {
@Bean
fun producerFactory(): ProducerFactory<String, String> =
DefaultKafkaProducerFactory(
mutableMapOf<String, Any>(
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
),
)
@Bean
fun kafkaTemplate(): KafkaTemplate<String, String> = KafkaTemplate(producerFactory())
}
@@ -0,0 +1,32 @@
package space.nstart.pcp.routepassportconsumer.config
import jakarta.validation.constraints.NotBlank
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.validation.annotation.Validated
/**
* Типизированные Kafka topic-настройки сервиса.
*
* Все значения приходят из spring-cloud-config-server и валидируются при старте,
* чтобы сервис не запускался без обязательных имен входного и выходных topic'ов.
*/
@Validated
@ConfigurationProperties(prefix = "app.kafka.topics")
data class KafkaTopicsProperties(
/** Topic, из которого сервис читает входящие паспорта маршрутов. */
@field:NotBlank
val input: String = "",
/** DLQ topic для входящих паспортов маршрутов, которые не удалось обработать. */
@field:NotBlank
val inputDlq: String = "",
/** Topic, в который сервис будет публиковать сообщение об обработанном маршруте. */
@field:NotBlank
val processedRoute: String = "",
/** Topic, в который сервис будет публиковать сообщение геопривязки маршрута. */
@field:NotBlank
val routeGeoreference: String = "",
)
@@ -0,0 +1,23 @@
package space.nstart.pcp.routepassportconsumer.config
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
/**
* Базовая OpenAPI-конфигурация для route-processing-service.
*/
@Configuration
class OpenApiConfig {
@Bean
fun routeProcessingOpenApi(): OpenAPI {
return OpenAPI()
.info(
Info()
.title("PCP Route Processing Service API")
.description("REST API сервиса обработки паспортов маршрутов PCP.")
.version("v1"),
)
}
}
@@ -0,0 +1,25 @@
package space.nstart.pcp.routepassportconsumer.config
import jakarta.validation.constraints.Min
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.validation.annotation.Validated
/**
* Настройки bounded retry для input Kafka consumer route passport flow.
*/
@Validated
@ConfigurationProperties(prefix = "app.route-passport.consumer.retry")
data class RoutePassportConsumerRetryProperties(
/** Максимальное количество попыток обработки, включая первичную delivery. */
@field:Min(1)
val maxAttempts: Long = 3,
/** Начальная задержка retry в миллисекундах. */
@field:Min(0)
val backoffMs: Long = 1_000,
/** Максимальная задержка между retry в миллисекундах. */
@field:Min(0)
val maxBackoffMs: Long = 30_000,
)
@@ -0,0 +1,45 @@
package space.nstart.pcp.routepassportconsumer.controller
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.core.io.ClassPathResource
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties
import java.nio.charset.StandardCharsets
@RestController
@RequestMapping("/api/route-processing")
@Tag(name = "Route Processing", description = "Тестовые endpoint'ы сервиса обработки маршрутов")
class RouteProcessingTestController(
private val kafkaTemplate: KafkaTemplate<String, String>,
private val kafkaTopicsProperties: KafkaTopicsProperties,
) {
@GetMapping("/test")
@Operation(summary = "Проверка доступности сервиса")
fun test(): Map<String, String> = mapOf(
"status" to "ok",
"service" to "pcp-route-processing-service",
)
@PostMapping("/test/send-passport-message")
@Operation(summary = "Отправить тестовый паспорт маршрута в Kafka")
fun sendPassportMessage(): Map<String, String> {
val payload = ClassPathResource("test-passport-message.json")
.inputStream
.readAllBytes()
.toString(StandardCharsets.UTF_8)
kafkaTemplate.send(kafkaTopicsProperties.input, payload)
return mapOf(
"status" to "sent",
"topic" to kafkaTopicsProperties.input,
"resource" to "classpath:test-passport-message.json",
)
}
}
@@ -0,0 +1,108 @@
package space.nstart.pcp.routepassportconsumer.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import jakarta.persistence.Version
import org.hibernate.annotations.JdbcTypeCode
import org.hibernate.type.SqlTypes
import tools.jackson.databind.JsonNode
import java.math.BigDecimal
import java.time.LocalDateTime
import java.util.UUID
@Entity
@Table(name = "route_passports")
class RoutePassportEntity(
@Id
@Column(name = "route_id", nullable = false)
var routeId: UUID,
@Column(name = "route_name_full", nullable = false, columnDefinition = "TEXT")
var routeNameFull: String,
@Column(name = "route_name_short", nullable = false, length = 255)
var routeNameShort: String,
@Column(name = "ka_short", nullable = false, length = 64)
var kaShort: String,
@Column(name = "ka_full", nullable = false, length = 255)
var kaFull: String,
@Column(name = "orbit_number", nullable = false)
var orbitNumber: Long,
@Column(name = "orbit_state", nullable = false, length = 64)
var orbitState: String,
@Column(name = "interval_begin", nullable = false)
var intervalBegin: LocalDateTime,
@Column(name = "interval_end", nullable = false)
var intervalEnd: LocalDateTime,
@Column(name = "roll_angle_min", nullable = false)
var rollAngleMin: BigDecimal,
@Column(name = "roll_angle_max", nullable = false)
var rollAngleMax: BigDecimal,
@Column(name = "visir_angle_min", nullable = false)
var visirAngleMin: BigDecimal,
@Column(name = "visir_angle_max", nullable = false)
var visirAngleMax: BigDecimal,
@Column(name = "resolution_range", nullable = false)
var resolutionRange: BigDecimal,
@Column(name = "resolution_azimuth", nullable = false)
var resolutionAzimuth: BigDecimal,
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "polarisation", nullable = false, columnDefinition = "jsonb")
var polarisation: JsonNode,
@Column(name = "processing_level", nullable = false, length = 64)
var processingLevel: String,
@Column(name = "geometry_wkt", nullable = false, columnDefinition = "TEXT")
var geometryWkt: String,
@Column(name = "route_status", nullable = false, length = 32)
var routeStatus: String,
@Column(name = "trace_id", length = 255)
var traceId: String? = null,
@Column(name = "correlation_id", length = 255)
var correlationId: String? = null,
@Column(name = "source_topic", length = 255)
var sourceTopic: String? = null,
@Column(name = "source_partition")
var sourcePartition: Int? = null,
@Column(name = "source_offset")
var sourceOffset: Long? = null,
@Column(name = "source_key", length = 255)
var sourceKey: String? = null,
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "raw_payload", columnDefinition = "jsonb")
var rawPayload: JsonNode? = null,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime,
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime,
@Version
@Column(name = "version", nullable = false)
var version: Long = 0,
)
@@ -0,0 +1,69 @@
package space.nstart.pcp.routepassportconsumer.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import org.hibernate.annotations.JdbcTypeCode
import org.hibernate.type.SqlTypes
import tools.jackson.databind.JsonNode
import java.time.LocalDateTime
import java.util.UUID
@Entity
@Table(name = "route_passport_outbox")
class RoutePassportOutboxEntity(
@Id
@Column(name = "id", nullable = false)
var id: UUID,
@Column(name = "route_id", nullable = false)
var routeId: UUID,
@Column(name = "event_type", nullable = false, length = 128)
var eventType: String,
@Column(name = "topic", nullable = false, length = 255)
var topic: String,
@Column(name = "message_key", nullable = false, length = 255)
var messageKey: String,
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "payload", nullable = false, columnDefinition = "jsonb")
var payload: JsonNode,
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "headers", columnDefinition = "jsonb")
var headers: JsonNode? = null,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 32)
var status: RoutePassportOutboxStatus,
@Column(name = "attempts", nullable = false)
var attempts: Int,
@Column(name = "max_attempts", nullable = false)
var maxAttempts: Int,
@Column(name = "next_attempt_at", nullable = false)
var nextAttemptAt: LocalDateTime,
@Column(name = "locked_until")
var lockedUntil: LocalDateTime? = null,
@Column(name = "last_error", columnDefinition = "TEXT")
var lastError: String? = null,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime,
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime,
@Column(name = "published_at")
var publishedAt: LocalDateTime? = null,
)
@@ -0,0 +1,10 @@
package space.nstart.pcp.routepassportconsumer.entity
/**
* Состояние события outbox в жизненном цикле публикации.
*/
enum class RoutePassportOutboxStatus {
PENDING,
PUBLISHED,
FAILED,
}
@@ -0,0 +1,6 @@
package space.nstart.pcp.routepassportconsumer.exception
class RoutePassportContractException(
message: String,
cause: Throwable? = null,
) : RuntimeException(message, cause)
@@ -0,0 +1,95 @@
package space.nstart.pcp.routepassportconsumer.listener
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.slf4j.LoggerFactory
import org.springframework.kafka.annotation.KafkaListener
import org.springframework.kafka.support.Acknowledgment
import org.springframework.stereotype.Component
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
/**
* Kafka listener, который получает JSON паспорта маршрута и передаёт его в транзакционный ingestion.
*/
@Component
class RoutePassportMessageListener(
/** Сервис парсинга входного JSON сообщения в итоговый DTO. */
private val routePassportJsonParserService: RoutePassportJsonParserService,
/** Сервис транзакционного сохранения паспорта маршрута и outbox-событий. */
private val routePassportIngestionService: RoutePassportIngestionService,
/** Набор Kafka topic'ов, который приходит из config server и валидируется при старте. */
val kafkaTopicsProperties: KafkaTopicsProperties,
) {
private val log = LoggerFactory.getLogger(this::class.java)
/**
* Обрабатывает входное сообщение Kafka.
*
* Offset подтверждается только после успешного возврата из transactional ingestion.
*/
@KafkaListener(
topics = ["#{__listener.kafkaTopicsProperties.input}"],
groupId = "\${spring.kafka.consumer.group-id}",
)
fun listen(
record: ConsumerRecord<String, String>,
acknowledgment: Acknowledgment,
) {
val rawPayload = record.value()
val sourceRecord = RoutePassportSourceRecordMetadata(
topic = record.topic(),
partition = record.partition(),
offset = record.offset(),
key = record.key(),
)
try {
log.debug(
"Kafka record received: sourceTopic={}, partition={}, offset={}, key={}, payloadSize={}",
sourceRecord.topic,
sourceRecord.partition,
sourceRecord.offset,
sourceRecord.key,
rawPayload.length,
)
val parsedMessage = routePassportJsonParserService.parse(rawPayload)
routePassportIngestionService.ingest(
parsedMessage = parsedMessage,
rawPayload = rawPayload,
sourceRecord = sourceRecord,
)
// Transactional ingest commits before returning; only then the Kafka offset is acknowledged.
acknowledgment.acknowledge()
log.info(
"Route passport ingested: sourceTopic={}, partition={}, offset={}, key={}, payloadSize={}, routeId={}, traceId={}, correlationId={}",
sourceRecord.topic,
sourceRecord.partition,
sourceRecord.offset,
sourceRecord.key,
rawPayload.length,
parsedMessage.routePassportDto.routeId,
parsedMessage.metadata.traceId,
parsedMessage.metadata.correlationId,
)
} catch (exception: Exception) {
log.error(
"Failed to process Kafka record: sourceTopic={}, partition={}, offset={}, key={}, error={}",
sourceRecord.topic,
sourceRecord.partition,
sourceRecord.offset,
sourceRecord.key,
exception.message,
exception,
)
throw exception
}
}
}
@@ -0,0 +1,26 @@
package space.nstart.pcp.routepassportconsumer.repository
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxEntity
import java.util.UUID
interface RoutePassportOutboxRepository : JpaRepository<RoutePassportOutboxEntity, UUID> {
fun existsByRouteIdAndEventType(routeId: UUID, eventType: String): Boolean
@Query(
value = """
SELECT *
FROM route_passport_outbox
WHERE status = 'PENDING'
AND next_attempt_at <= now()
AND (locked_until IS NULL OR locked_until < now())
ORDER BY created_at
LIMIT :limit
FOR UPDATE SKIP LOCKED
""",
nativeQuery = true,
)
fun findDueForUpdateSkipLocked(@Param("limit") limit: Int): List<RoutePassportOutboxEntity>
}
@@ -0,0 +1,7 @@
package space.nstart.pcp.routepassportconsumer.repository
import org.springframework.data.jpa.repository.JpaRepository
import space.nstart.pcp.routepassportconsumer.entity.RoutePassportEntity
import java.util.UUID
interface RoutePassportRepository : JpaRepository<RoutePassportEntity, UUID>
@@ -0,0 +1,143 @@
package space.nstart.pcp.routepassportconsumer.service
import org.locationtech.jts.geom.Coordinate
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.geom.GeometryFactory
import org.locationtech.jts.geom.LinearRing
import org.locationtech.jts.geom.Polygon
import org.locationtech.jts.io.WKTWriter
import org.springframework.stereotype.Service
import space.nstart.pcp.pcp_types_lib.dto.routes.RawGeoJsonGeometryDto
import space.nstart.pcp.routepassportconsumer.exception.RoutePassportContractException
import tools.jackson.databind.JsonNode
/**
* Конвертирует поддерживаемую GeoJSON geometry входного паспорта маршрута в WKT.
*/
@Service
open class RouteGeometryConverter {
private val geometryFactory = GeometryFactory()
private val wktWriter = WKTWriter()
open fun convert(geometry: RawGeoJsonGeometryDto?): String {
if (geometry == null) {
throw RoutePassportContractException("Отсутствует обязательное поле geometry")
}
val jtsGeometry = when (geometry.type) {
"Polygon" -> toPolygon(geometry.coordinates, fieldName = "geometry.coordinates")
"MultiPolygon" -> toMultiPolygon(geometry.coordinates)
else -> throw RoutePassportContractException("Неподдерживаемый тип geometry: ${geometry.type}")
}
return writeCompactWkt(jtsGeometry)
}
private fun toMultiPolygon(coordinatesNode: JsonNode): Geometry {
requireArray(coordinatesNode, "geometry.coordinates")
if (coordinatesNode.size() == 0) {
throw RoutePassportContractException("MultiPolygon geometry.coordinates не должен быть пустым")
}
val polygons = coordinatesNode.mapIndexed { index, polygonNode ->
toPolygon(polygonNode, fieldName = "geometry.coordinates[$index]")
}.toTypedArray()
return geometryFactory.createMultiPolygon(polygons)
}
private fun toPolygon(
coordinatesNode: JsonNode,
fieldName: String,
): Polygon {
requireArray(coordinatesNode, fieldName)
if (coordinatesNode.size() == 0) {
throw RoutePassportContractException("Polygon $fieldName должен содержать хотя бы один linear ring")
}
val shell = toLinearRing(coordinatesNode[0], "$fieldName[0]")
val holes = coordinatesNode.drop(1).mapIndexed { index, ringNode ->
toLinearRing(ringNode, "$fieldName[${index + 1}]")
}.toTypedArray()
return try {
geometryFactory.createPolygon(shell, holes)
} catch (exception: IllegalArgumentException) {
throw RoutePassportContractException("Некорректная структура Polygon $fieldName", exception)
}
}
private fun toLinearRing(
ringNode: JsonNode,
fieldName: String,
): LinearRing {
requireArray(ringNode, fieldName)
if (ringNode.size() < MIN_LINEAR_RING_POINTS) {
throw RoutePassportContractException(
"Linear ring $fieldName должен содержать минимум $MIN_LINEAR_RING_POINTS точки",
)
}
val coordinates = ringNode.mapIndexed { index, coordinateNode ->
toCoordinate(coordinateNode, "$fieldName[$index]")
}.toTypedArray()
if (!coordinates.first().equals2D(coordinates.last())) {
throw RoutePassportContractException("Linear ring $fieldName должен быть замкнут")
}
return try {
geometryFactory.createLinearRing(coordinates)
} catch (exception: IllegalArgumentException) {
throw RoutePassportContractException("Некорректная структура linear ring $fieldName", exception)
}
}
private fun toCoordinate(
coordinateNode: JsonNode,
fieldName: String,
): Coordinate {
requireArray(coordinateNode, fieldName)
if (coordinateNode.size() < MIN_COORDINATE_VALUES) {
throw RoutePassportContractException("Точка $fieldName должна содержать минимум две координаты")
}
if (!coordinateNode[0].isNumber || !coordinateNode[1].isNumber) {
throw RoutePassportContractException("Точка $fieldName должна содержать числовые longitude и latitude")
}
return try {
Coordinate(
coordinateNode[0].decimalValue().toDouble(),
coordinateNode[1].decimalValue().toDouble(),
)
} catch (exception: RuntimeException) {
throw RoutePassportContractException("Точка $fieldName содержит некорректные координаты", exception)
}
}
private fun requireArray(
node: JsonNode,
fieldName: String,
) {
if (!node.isArray) {
throw RoutePassportContractException("Поле $fieldName должно быть массивом GeoJSON")
}
}
private fun writeCompactWkt(geometry: Geometry): String =
when (geometry.geometryType) {
"Polygon" -> wktWriter.write(geometry).removePrefix("POLYGON ").let { "POLYGON$it" }
"MultiPolygon" -> wktWriter.write(geometry).removePrefix("MULTIPOLYGON ").let { "MULTIPOLYGON$it" }
else -> wktWriter.write(geometry)
}
private companion object {
const val MIN_COORDINATE_VALUES = 2
const val MIN_LINEAR_RING_POINTS = 4
}
}
@@ -0,0 +1,200 @@
package space.nstart.pcp.routepassportconsumer.service
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
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.message.KafkaMessage
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.JsonNode
import tools.jackson.databind.ObjectMapper
import java.time.LocalDateTime
import java.util.UUID
/**
* Сохраняет разобранный route passport и outbox-события в одной DB transaction.
*/
@Service
class RoutePassportIngestionService(
private val routePassportRepository: RoutePassportRepository,
private val routePassportOutboxRepository: RoutePassportOutboxRepository,
private val objectMapper: ObjectMapper,
private val kafkaTopicsProperties: KafkaTopicsProperties,
) {
@Transactional
fun ingest(
parsedMessage: ParsedRoutePassportMessage,
rawPayload: String,
sourceRecord: RoutePassportSourceRecordMetadata,
) {
val now = LocalDateTime.now()
val routePassportDto = parsedMessage.routePassportDto
val routePassportEntity = routePassportRepository.findById(routePassportDto.routeId)
.map { existingEntity ->
existingEntity.applyRoutePassport(
routePassportDto = routePassportDto,
parsedMessage = parsedMessage,
rawPayload = rawPayload,
sourceRecord = sourceRecord,
updatedAt = now,
)
}
.orElseGet {
routePassportDto.toEntity(
parsedMessage = parsedMessage,
rawPayload = rawPayload,
sourceRecord = sourceRecord,
now = now,
)
}
routePassportRepository.save(routePassportEntity)
createOutboxIfAbsent(
eventType = PcpKafkaEvent.ModeStatusChangedEvent,
topic = kafkaTopicsProperties.processedRoute,
parsedMessage = parsedMessage,
now = now,
)
createOutboxIfAbsent(
eventType = PcpKafkaEvent.RouteGeoRefEvent,
topic = kafkaTopicsProperties.routeGeoreference,
parsedMessage = parsedMessage,
now = now,
)
}
private fun createOutboxIfAbsent(
eventType: PcpKafkaEvent,
topic: String,
parsedMessage: ParsedRoutePassportMessage,
now: LocalDateTime,
) {
val routeId = parsedMessage.routePassportDto.routeId
val eventTypeName = eventType.name
if (routePassportOutboxRepository.existsByRouteIdAndEventType(routeId, eventTypeName)) {
return
}
routePassportOutboxRepository.save(
RoutePassportOutboxEntity(
id = UUID.randomUUID(),
routeId = routeId,
eventType = eventTypeName,
topic = topic,
messageKey = routeId.toString(),
payload = buildOutboxPayload(eventType, parsedMessage),
headers = null,
status = RoutePassportOutboxStatus.PENDING,
attempts = 0,
maxAttempts = 10,
nextAttemptAt = now,
lockedUntil = null,
lastError = null,
createdAt = now,
updatedAt = now,
publishedAt = null,
),
)
}
private fun buildOutboxPayload(
eventType: PcpKafkaEvent,
parsedMessage: ParsedRoutePassportMessage,
): JsonNode {
val message = KafkaMessage(
type = eventType,
data = parsedMessage.routePassportDto,
traceId = parsedMessage.metadata.traceId,
correlationId = parsedMessage.metadata.correlationId,
group = "pcp",
).apply {
source = "route-processing"
}
return objectMapper.valueToTree(message)
}
private fun RoutePassportEntity.applyRoutePassport(
routePassportDto: RoutePassportDto,
parsedMessage: ParsedRoutePassportMessage,
rawPayload: String,
sourceRecord: RoutePassportSourceRecordMetadata,
updatedAt: LocalDateTime,
): RoutePassportEntity {
routeNameFull = routePassportDto.routeNameFull
routeNameShort = routePassportDto.routeNameShort
kaShort = routePassportDto.kaShort
kaFull = routePassportDto.kaFull
orbitNumber = routePassportDto.orbitNumber
orbitState = routePassportDto.orbitState
intervalBegin = routePassportDto.intervalBegin
intervalEnd = routePassportDto.intervalEnd
rollAngleMin = routePassportDto.rollAngle.min
rollAngleMax = routePassportDto.rollAngle.max
visirAngleMin = routePassportDto.visirAngle.min
visirAngleMax = routePassportDto.visirAngle.max
resolutionRange = routePassportDto.resolutionRange
resolutionAzimuth = routePassportDto.resolutionAzimuth
polarisation = objectMapper.valueToTree(routePassportDto.polarisation)
processingLevel = routePassportDto.processingLevel
geometryWkt = routePassportDto.geometry
routeStatus = routePassportDto.routeStatus.name
traceId = parsedMessage.metadata.traceId
correlationId = parsedMessage.metadata.correlationId
sourceTopic = sourceRecord.topic
sourcePartition = sourceRecord.partition
sourceOffset = sourceRecord.offset
sourceKey = sourceRecord.key
this.rawPayload = objectMapper.readTree(rawPayload)
this.updatedAt = updatedAt
return this
}
private fun RoutePassportDto.toEntity(
parsedMessage: ParsedRoutePassportMessage,
rawPayload: String,
sourceRecord: RoutePassportSourceRecordMetadata,
now: LocalDateTime,
): RoutePassportEntity =
RoutePassportEntity(
routeId = routeId,
routeNameFull = routeNameFull,
routeNameShort = routeNameShort,
kaShort = kaShort,
kaFull = kaFull,
orbitNumber = orbitNumber,
orbitState = orbitState,
intervalBegin = intervalBegin,
intervalEnd = intervalEnd,
rollAngleMin = rollAngle.min,
rollAngleMax = rollAngle.max,
visirAngleMin = visirAngle.min,
visirAngleMax = visirAngle.max,
resolutionRange = resolutionRange,
resolutionAzimuth = resolutionAzimuth,
polarisation = objectMapper.valueToTree(polarisation),
processingLevel = processingLevel,
geometryWkt = geometry,
routeStatus = routeStatus.name,
traceId = parsedMessage.metadata.traceId,
correlationId = parsedMessage.metadata.correlationId,
sourceTopic = sourceRecord.topic,
sourcePartition = sourceRecord.partition,
sourceOffset = sourceRecord.offset,
sourceKey = sourceRecord.key,
rawPayload = objectMapper.readTree(rawPayload),
createdAt = now,
updatedAt = now,
)
}
@@ -0,0 +1,108 @@
package space.nstart.pcp.routepassportconsumer.service
import org.springframework.stereotype.Service
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.RawFeaturePassportJsonDto
import space.nstart.pcp.pcp_types_lib.dto.routes.RawPassportEnvelopeDto
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.exception.RoutePassportContractException
import tools.jackson.core.JacksonException
import tools.jackson.databind.ObjectMapper
import java.math.BigDecimal
import java.nio.charset.StandardCharsets
import java.time.LocalDateTime
import java.util.UUID
/**
* Сервис, который парсит JSON паспорта маршрута в итоговый DTO.
*/
@Service
class RoutePassportJsonParserService(
/** Jackson ObjectMapper, который выполняет базовую десериализацию JSON. */
private val objectMapper: ObjectMapper,
/** Converter GeoJSON geometry входного паспорта маршрута в WKT. */
private val routeGeometryConverter: RouteGeometryConverter,
) {
/**
* Парсит входной JSON и возвращает готовый DTO.
*
* @param json исходное Kafka-сообщение envelope-формата с полезной нагрузкой в поле `data`.
* @return DTO с уже преобразованными полями.
*/
fun parse(json: String): ParsedRoutePassportMessage {
val rawEnvelope: RawPassportEnvelopeDto = try {
objectMapper.readValue(json, RawPassportEnvelopeDto::class.java)
} catch (exception: JacksonException) {
throw RoutePassportContractException("Некорректный JSON contract паспорта маршрута", exception)
}
val routePassportDto = rawEnvelope.data.toRoutePassportDto()
return ParsedRoutePassportMessage(
metadata = RoutePassportMessageMetadata(
traceId = rawEnvelope.traceId,
correlationId = rawEnvelope.correlationId,
),
routePassportDto = routePassportDto,
)
}
/**
* Преобразует feature из envelope-сообщения в бизнес DTO.
*/
private fun RawFeaturePassportJsonDto.toRoutePassportDto(): RoutePassportDto {
val routePassportDto = RoutePassportDto(
routeId = routeId ?: UUID.nameUUIDFromBytes(id.toByteArray(StandardCharsets.UTF_8)),
kaShort = properties.missionId,
kaFull = properties.platform,
routeNameFull = id,
routeNameShort = collection,
orbitNumber = properties.orbitNumber.toLong(),
orbitState = properties.orbitState,
intervalBegin = LocalDateTime.parse(properties.intervalBegin),
intervalEnd = LocalDateTime.parse(properties.intervalEnd),
rollAngle = properties.rollAngle.toAngleRangeDto(fieldName = "rollAngle"),
visirAngle = properties.visirAngle.toAngleRangeDto(fieldName = "visirAngle"),
resolutionRange = properties.resolutionRange.toBigDecimal(),
resolutionAzimuth = properties.resolutionAzimuth.toBigDecimal(),
polarisation = properties.polarisation,
processingLevel = properties.processingLevel,
geometry = routeGeometryConverter.convert(geometry),
)
return routePassportDto
}
/**
* Преобразует список чисел в DTO диапазона.
*
* @receiver список значений диапазона.
* @param fieldName имя поля для текста ошибки.
* @return DTO диапазона с минимальным и максимальным значением.
*/
private fun List<BigDecimal>.toAngleRangeDto(fieldName: String): AngleRangeDto {
if (this.isEmpty()) {
throw RoutePassportContractException("Поле $fieldName не должно быть пустым")
}
/** Минимальное значение диапазона. */
val minValue: BigDecimal = this.minOrNull()
?: throw RoutePassportContractException("Не удалось определить минимум для поля $fieldName")
/** Максимальное значение диапазона. */
val maxValue: BigDecimal = this.maxOrNull()
?: throw RoutePassportContractException("Не удалось определить максимум для поля $fieldName")
/** DTO диапазона после вычисления минимума и максимума. */
val angleRangeDto: AngleRangeDto = AngleRangeDto(
min = minValue,
max = maxValue,
)
return angleRangeDto
}
}
@@ -0,0 +1,113 @@
package space.nstart.pcp.routepassportconsumer.service
import org.apache.kafka.clients.producer.ProducerRecord
import org.slf4j.LoggerFactory
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.support.SendResult
import org.springframework.stereotype.Service
import space.nstart.pcp.routepassportconsumer.config.KafkaTopicsProperties
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 java.nio.charset.StandardCharsets
import java.util.concurrent.CompletableFuture
/**
* Сервис публикации обработанного маршрута в Kafka.
*/
@Service
class RoutePassportKafkaPublisherService(
/** ObjectMapper сериализует итоговый DTO в JSON перед отправкой. */
private val objectMapper: ObjectMapper,
/** KafkaTemplate отправляет одно и то же сообщение в несколько topic'ов. */
private val kafkaTemplate: KafkaTemplate<String, String>,
/** Набор Kafka topic'ов, в которые публикуется результат обработки. */
private val kafkaTopicsProperties: KafkaTopicsProperties,
) {
private val log = LoggerFactory.getLogger(this::class.java)
/**
* Публикует полный RoutePassportDto в оба выходных topic'а.
*/
fun publish(parsedMessage: ParsedRoutePassportMessage) {
val routePassportDto = parsedMessage.routePassportDto
log.info(
"Publishing route passport: routeNameFull={}, processedRouteTopic={}, routeGeoreferenceTopic={}",
routePassportDto.routeNameFull,
kafkaTopicsProperties.processedRoute,
kafkaTopicsProperties.routeGeoreference,
)
sendWithLogging(
topic = kafkaTopicsProperties.processedRoute,
eventType = PcpKafkaEvent.ModeStatusChangedEvent,
payload = buildPayload(
eventType = PcpKafkaEvent.ModeStatusChangedEvent,
parsedMessage = parsedMessage,
),
traceId = parsedMessage.metadata.traceId,
routeNameFull = routePassportDto.routeNameFull,
)
sendWithLogging(
topic = kafkaTopicsProperties.routeGeoreference,
eventType = PcpKafkaEvent.RouteGeoRefEvent,
payload = buildPayload(
eventType = PcpKafkaEvent.RouteGeoRefEvent,
parsedMessage = parsedMessage,
),
traceId = parsedMessage.metadata.traceId,
routeNameFull = routePassportDto.routeNameFull,
)
}
private fun buildPayload(eventType: PcpKafkaEvent, parsedMessage: ParsedRoutePassportMessage): String {
val message = KafkaMessage(
type = eventType,
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,
)
}
}
}
}
@@ -0,0 +1,11 @@
package space.nstart.pcp.routepassportconsumer.service
/**
* Metadata входной Kafka record, из которой был получен route passport.
*/
data class RoutePassportSourceRecordMetadata(
val topic: String,
val partition: Int,
val offset: Long,
val key: String?,
)
@@ -0,0 +1,28 @@
spring:
application:
name: pcp-route-processing-service
profiles:
default: local
config:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://192.168.100.160:38888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:master}
datasource:
driver-class-name: org.postgresql.Driver
url: ${PCP_ROUTE_PROCESSING_DATASOURCE_URL:jdbc:postgresql://${PCP_POSTGRES_HOST:localhost}:${PCP_POSTGRES_PORT:5432}/pcp_route_processing}
username: ${PCP_ROUTE_PROCESSING_DATASOURCE_USERNAME:postgres}
password: ${PCP_ROUTE_PROCESSING_DATASOURCE_PASSWORD:password}
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
format_sql: true
flyway:
enabled: true
baseline-on-migrate: true
locations: classpath:db/migration
@@ -0,0 +1,46 @@
CREATE TABLE route_passports (
route_id UUID PRIMARY KEY,
route_name_full TEXT NOT NULL,
route_name_short VARCHAR(255) NOT NULL,
ka_short VARCHAR(64) NOT NULL,
ka_full VARCHAR(255) NOT NULL,
orbit_number BIGINT NOT NULL,
orbit_state VARCHAR(64) NOT NULL,
interval_begin TIMESTAMP NOT NULL,
interval_end TIMESTAMP NOT NULL,
roll_angle_min NUMERIC NOT NULL,
roll_angle_max NUMERIC NOT NULL,
visir_angle_min NUMERIC NOT NULL,
visir_angle_max NUMERIC NOT NULL,
resolution_range NUMERIC NOT NULL,
resolution_azimuth NUMERIC NOT NULL,
polarisation JSONB NOT NULL,
processing_level VARCHAR(64) NOT NULL,
geometry_wkt TEXT NOT NULL,
route_status VARCHAR(32) NOT NULL,
trace_id VARCHAR(255),
correlation_id VARCHAR(255),
source_topic VARCHAR(255),
source_partition INTEGER,
source_offset BIGINT,
source_key VARCHAR(255),
raw_payload JSONB,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_route_passports_interval
ON route_passports (interval_begin, interval_end);
CREATE INDEX idx_route_passports_ka_orbit
ON route_passports (ka_short, orbit_number);
CREATE INDEX idx_route_passports_status
ON route_passports (route_status);
CREATE UNIQUE INDEX ux_route_passports_source_record
ON route_passports (source_topic, source_partition, source_offset)
WHERE source_topic IS NOT NULL
AND source_partition IS NOT NULL
AND source_offset IS NOT NULL;
@@ -0,0 +1,38 @@
CREATE TABLE route_passport_outbox (
id UUID PRIMARY KEY,
route_id UUID NOT NULL,
event_type VARCHAR(128) NOT NULL,
topic VARCHAR(255) NOT NULL,
message_key VARCHAR(255) NOT NULL,
payload JSONB NOT NULL,
headers JSONB,
status VARCHAR(32) NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 10,
next_attempt_at TIMESTAMP NOT NULL,
locked_until TIMESTAMP,
last_error TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
published_at TIMESTAMP,
CONSTRAINT fk_route_passport_outbox_route
FOREIGN KEY (route_id)
REFERENCES route_passports (route_id)
ON DELETE CASCADE,
CONSTRAINT ux_route_passport_outbox_route_event
UNIQUE (route_id, event_type),
CONSTRAINT chk_route_passport_outbox_status
CHECK (status IN ('PENDING', 'PUBLISHED', 'FAILED'))
);
CREATE INDEX idx_route_passport_outbox_due
ON route_passport_outbox (status, next_attempt_at, created_at);
CREATE INDEX idx_route_passport_outbox_locked_until
ON route_passport_outbox (locked_until);
CREATE INDEX idx_route_passport_outbox_route_id
ON route_passport_outbox (route_id);
@@ -0,0 +1,207 @@
{
"id": "51b07d12-1823-4bd5-b55d-de02a5304c03",
"traceid": "c0dd08ea-dd64-4633-8845-d1b163bdf506",
"correlationid": "db605d5f-77e9-45b4-82cc-709837306257",
"group": "stac_test",
"source": "stac-writer-service",
"type": "METADATA_NORMALIZED",
"time": "2026-03-31T09:39:13.251462",
"data": {
"id": "S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE",
"routeId": "51b07d12-1823-4bd5-b55d-de02a5304c03",
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
67.675827,
38.114815
],
[
64.779434,
38.514816
],
[
65.094017,
40.015545
],
[
68.05294,
39.616795
],
[
67.675827,
38.114815
]
]
]
},
"bbox": [
64.779434,
40.015545,
68.05294,
38.114815
],
"properties": {
"datetime": "2026-03-31T12:08:30.570573Z",
"created": null,
"updated": null,
"platform": "sentinel-1a",
"instruments": null,
"processing:level": "GRD",
"route:orbit_number": "59796",
"missionId": "S1A",
"sat:orbit_state": "Descending",
"polarisation": [
"VH",
"VV"
],
"rollAngle": [
30.07864486915724,
30.09378936608302
],
"visirAngle": [
30.63331560676386,
46.07086702096005
],
"intervalBegin": "2025-06-25T01:38:54.481190",
"intervalEnd": "2025-06-25T01:39:19.479675",
"resolutionRange": "1.000000e+01",
"resolutionAzimuth": "1.000000e+01"
},
"links": [
{
"rel": "self",
"href": "http://app-opensearch:8082/collections/S1A-GRDH/items/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE",
"type": "application/geo+json",
"title": null
},
{
"rel": "parent",
"href": "http://app-opensearch:8082/collections/S1A-GRDH",
"type": "application/json",
"title": null
},
{
"rel": "collection",
"href": "http://app-opensearch:8082/collections/S1A-GRDH",
"type": "application/json",
"title": null
},
{
"rel": "root",
"href": "http://app-opensearch:8082",
"type": "application/json",
"title": null
}
],
"assets": {
"schema-calibration-vh": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=26988c6a3490f71382ae9e61ff0d7b59aca32ec652993a785d24e8dd81e70939",
"title": "xml",
"description": null,
"type": "application/xml",
"roles": [
"metadata"
]
},
"schema-calibration-vv": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=edd6957a0404ead07e7a31d0759db26b3a6ad33394247c177dc41a7abf5972d0",
"title": "xml",
"description": null,
"type": "application/xml",
"roles": [
"metadata"
]
},
"schema-noise-vh": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=3efe46114ed6dfeac898963510d3ba9c8b537c803b1fa69a5a03d5b462a1665c",
"title": "xml",
"description": null,
"type": "application/xml",
"roles": [
"metadata"
]
},
"schema-noise-vv": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=0ca91479ba4132dbf13c2637f97567eb29ee9ca818009eaf501072995fe784ca",
"title": "xml",
"description": null,
"type": "application/xml",
"roles": [
"metadata"
]
},
"schema-product-vh": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=39817f0b7610c0b20a23d0fae24e7183f0d3719ba6ab2f315c320c9c6530274f",
"title": "xml",
"description": null,
"type": "application/xml",
"roles": [
"metadata"
]
},
"schema-product-vv": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=85d28bc2bb94724b4db7df84121d4252b423fa429204e4b25e06b9d725f3aeee",
"title": "xml",
"description": null,
"type": "application/xml",
"roles": [
"metadata"
]
},
"safe-manifest": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/manifest.safe?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbb22d57d031a412e2b46273edf32cfcb7fbdae1fa1fcc74bdcaa39a5e086f53",
"title": "safe",
"description": null,
"type": "application/xml",
"roles": [
"metadata"
]
},
"vh": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120824Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbecc9e04e976751e855f53e9a9a8db5d6365a30ac7ec116f31b0936ee375648",
"title": "tif",
"description": null,
"type": "image/tiff; application=geotiff; profile=cloud-optimized",
"roles": [
"visual"
]
},
"vv": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=34cf1e5f5174b7cb5ca5d173ff699923819d5df28486436d972efa59ac9b1ddb",
"title": "tif",
"description": null,
"type": "image/tiff; application=geotiff; profile=cloud-optimized",
"roles": [
"visual"
]
},
"kml": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/map-overlay.kml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=92f63b9f463041c4ed87b3cb508ba8c57fd2db8ea2d22026087ba57d2ef88f54",
"title": "kml",
"description": null,
"type": "application/kml",
"roles": [
"metadata"
]
},
"thumbnail": {
"href": "http://192.168.100.160:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/quick-look.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=19bebb1e446add1d6e0898b4329d2a3e016f59087719ce2b51dff5c487835cb0",
"title": "png",
"description": null,
"type": "image/png",
"roles": [
"thumbnail",
"overview"
]
}
},
"collection": "S1A-GRDH",
"stac_version": "1.0.0",
"stac_extensions": null
},
"datacontenttype": "application/json",
"specversion": "1.0"
}
@@ -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