pcp-request-service: аудит-фиксы RS1/RS3/RS6/RS7

RS1 (#29): планировщик истечения заявок — RequestExpiryService
(@Scheduled + FOR UPDATE SKIP LOCKED, мультиинстанс-safe), refreshStatus
вызывается и при создании заявки.
RS7 (#35): единый топик pcp.request.status.v1 → geoportal (COMPLETED/EXPIRED
в payload), замена orphan pcp.request.completed.v1; эмиссия из matching и
expiry через тот же outbox; backoff/next_attempt_at/cap для outbox FAILED.
RS3 (#31): DefaultErrorHandler + DeadLetterPublishingRecoverer
(DLQ pcp.route.georeference-dlq.v1), bounded retry, контрактные ошибки в DLQ.
RS6 (#34): проброс coverageRequiredPercent из API, разведение терминов
покрытие/важность.

Доки: бэклог, аудит сервиса заданий, карты взаимодействий.
RS2 (#30) отложено до #28, RS5 (#33) — до роста требований к точности.
This commit is contained in:
Дмитрий Соловьев
2026-06-06 23:47:19 +03:00
parent 2499aebf2d
commit c1259c3105
33 changed files with 1600 additions and 139 deletions
@@ -49,8 +49,11 @@ class MigrationSchemaTest {
assertTrue(outboxEvents.contains("payload jsonb not null"))
assertTrue(outboxEvents.contains("status varchar(32) not null"))
assertTrue(outboxEvents.contains("attempts integer not null default 0"))
assertTrue(outboxEvents.contains("next_attempt_at timestamp not null"))
assertTrue(outboxEvents.contains("constraint uk_outbox_events_event_type_aggregate_id unique (event_type, aggregate_id)"))
assertTrue(schema.contains("on outbox_events(status, created_at)"))
assertTrue(schema.contains("on outbox_events(event_type, status, next_attempt_at, created_at)"))
}
@Test
@@ -0,0 +1,175 @@
package org.nstart.dep265.requestservice.config
import jakarta.validation.ConstraintViolation
import jakarta.validation.ConstraintViolationException
import org.apache.kafka.clients.consumer.Consumer
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.kafka.clients.producer.ProducerRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
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.SendResult
import java.nio.charset.StandardCharsets
import java.util.concurrent.CompletableFuture
class KafkaConsumerConfigTest {
private val contextRunner = ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(ConfigurationPropertiesAutoConfiguration::class.java))
.withUserConfiguration(TestConfiguration::class.java, KafkaConsumerConfig::class.java)
.withPropertyValues(
"spring.kafka.bootstrap-servers=localhost:9092",
"spring.kafka.consumer.group-id=pcp-request-service",
"spring.kafka.consumer.auto-offset-reset=earliest",
"app.kafka.consumer.route.dlq-topic=$DLQ_TOPIC",
"app.kafka.consumer.route.max-attempts=3",
"app.kafka.consumer.route.backoff-ms=0",
"app.kafka.consumer.route.max-backoff-ms=0",
)
@Test
fun `creates error handler and recoverer beans`() {
contextRunner.run { context ->
assertNotNull(context.getBean(DefaultErrorHandler::class.java))
assertNotNull(context.getBean(DeadLetterPublishingRecoverer::class.java))
val properties = context.getBean(RouteConsumerProperties::class.java)
assertEquals(DLQ_TOPIC, properties.dlqTopic)
assertEquals(3L, properties.maxAttempts)
}
}
@Test
fun `recoverer publishes original record to configured dlq topic with diagnostics headers`() {
contextRunner.run { context ->
val kafkaTemplate = context.kafkaTemplate()
val recoverer = context.getBean(DeadLetterPublishingRecoverer::class.java)
val record = consumerRecord()
recoverer.accept(record, ConstraintViolationException("route payload invalid", emptySet<ConstraintViolation<*>>()))
val published = capturePublishedRecord(kafkaTemplate)
assertEquals(DLQ_TOPIC, published.topic())
assertEquals(record.partition(), published.partition())
assertEquals(record.key(), published.key())
assertEquals(record.value(), published.value())
assertEquals("pcp-request-service", published.headers().stringHeader("pcp-service-name"))
assertEquals(
ConstraintViolationException::class.java.name,
published.headers().stringHeader("pcp-exception-class"),
)
assertEquals("route payload invalid", published.headers().stringHeader("pcp-exception-message"))
assertEquals(ROUTE_TOPIC, published.headers().stringHeader("pcp-original-topic"))
assertEquals("3", published.headers().stringHeader("pcp-original-partition"))
assertEquals("42", published.headers().stringHeader("pcp-original-offset"))
}
}
@Test
fun `contract violation is recovered to dlq without retry`() {
contextRunner.run { context ->
val kafkaTemplate = context.kafkaTemplate()
val errorHandler = context.getBean(DefaultErrorHandler::class.java)
val handled = errorHandler.handleOne(
ConstraintViolationException("route payload invalid", emptySet<ConstraintViolation<*>>()),
consumerRecord(),
mockConsumer(),
mock(MessageListenerContainer::class.java),
)
assertTrue(handled)
verify(kafkaTemplate, times(1)).send(anyProducerRecord())
}
}
@Test
fun `transient exception is 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")
// maxAttempts=3 → две первые доставки ретраятся (seek), на третьей — DLQ.
assertFalse(errorHandler.handleOne(exception, record, consumer, container))
assertFalse(errorHandler.handleOne(exception, record, consumer, container))
verify(kafkaTemplate, never()).send(anyProducerRecord())
assertTrue(errorHandler.handleOne(exception, record, consumer, container))
verify(kafkaTemplate, times(1)).send(anyProducerRecord())
}
}
private fun consumerRecord(): ConsumerRecord<String, String> =
ConsumerRecord(ROUTE_TOPIC, 3, 42L, "route-key", """{"routeId":"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.stringHeader(name: String): String? =
lastHeader(name)?.value()?.toString(StandardCharsets.UTF_8)
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(RouteConsumerProperties::class)
private class TestConfiguration {
@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 ROUTE_TOPIC = "pcp.route.georeference.v1"
const val DLQ_TOPIC = "pcp.route.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>
}
}
@@ -25,6 +25,7 @@ import org.nstart.dep265.requestservice.repository.RequestRepository
import org.nstart.dep265.requestservice.repository.RequestRsaParamsRepository
import org.nstart.dep265.requestservice.service.GeometryService
import org.nstart.dep265.requestservice.service.RequestGridProjectionService
import org.nstart.dep265.requestservice.service.RequestLifecycleService
import org.nstart.dep265.requestservice.service.RequestService
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
@@ -98,6 +99,7 @@ class RequestControllerOpenApiSkeletonTest {
requestCellRepository = requestCellRepository,
geometryService = GeometryService(),
requestGridProjectionService = mock(RequestGridProjectionService::class.java),
requestLifecycleService = RequestLifecycleService(GeometryService()),
entityManager = entityManager,
)
)
@@ -114,6 +116,7 @@ class RequestControllerOpenApiSkeletonTest {
@Test
fun `post v1 requests returns create response`() {
// Окно в будущем → приём детерминированно даёт ACCEPTED (createRequest зовёт refreshStatus).
mockMvc.perform(
post("/v1/requests")
.contentType(MediaType.APPLICATION_JSON)
@@ -125,8 +128,8 @@ class RequestControllerOpenApiSkeletonTest {
"name": "Тест1",
"geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))",
"importance": 10.0,
"beginDateTime": "2026-01-01T00:00:00Z",
"endDateTime": "2026-01-31T23:59:59Z",
"beginDateTime": "2090-01-01T00:00:00Z",
"endDateTime": "2090-01-31T23:59:59Z",
"kpp": [1, 2, 3],
"highPriorityTransmit": false,
"optics": {
@@ -45,6 +45,31 @@ class RequestApiDtoContractTest {
assertEquals(10.0, request.importance)
assertEquals(LocalDateTime.of(2026, 1, 1, 0, 0, 0), request.beginDateTime)
assertEquals(OpticsResultTypeDto.PANCHROMATIC, request.optics?.resultType)
// Поле опционально: при отсутствии в payload — порог покрытия по умолчанию 100%.
assertEquals(100.0, request.coverageRequiredPercent)
}
@Test
fun `create request dto binds explicit coverage required percent`() {
val request = objectMapper.readValue<CreateRequestDto>(
"""
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Тест1",
"geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))",
"importance": 10.0,
"beginDateTime": "2026-01-01T00:00:00",
"endDateTime": "2026-01-31T23:59:59",
"coverageRequiredPercent": 80.0,
"optics": {
"resultType": "PANCHROMATIC",
"resolution": 1.0
}
}
""".trimIndent(),
)
assertEquals(80.0, request.coverageRequiredPercent)
}
@Test
@@ -82,19 +82,77 @@ class OutboxEventRepositoryJpaTest @Autowired constructor(
)
outboxEventRepository.saveAllAndFlush(listOf(oldFailedEvent, newEvent, newerFailedEvent, publishedEvent, otherEventType))
val events = outboxEventRepository.findRequestCompletedPublishableForUpdateSkipLocked(
eventType = "REQUEST_COMPLETED",
val events = outboxEventRepository.findPublishableForUpdateSkipLocked(
eventType = "REQUEST_TERMINAL_STATUS",
now = LocalDateTime.parse("2026-06-06T00:00:00"),
maxAttempts = 10,
batchSize = 10,
)
assertEquals(listOf(newEvent.id, oldFailedEvent.id, newerFailedEvent.id), events.map { it.id })
}
@Test
@Transactional
fun `publishable query excludes events whose backoff has not elapsed`() {
val now = LocalDateTime.parse("2026-06-06T00:00:00")
val dueFailed = outboxEvent(
requestId = UUID.fromString("aaaaaaaa-0000-4000-8000-000000000001"),
status = OutboxEventStatus.FAILED,
attempts = 1,
nextAttemptAt = now.minusMinutes(1),
)
val backoffPendingFailed = outboxEvent(
requestId = UUID.fromString("bbbbbbbb-0000-4000-8000-000000000002"),
status = OutboxEventStatus.FAILED,
attempts = 1,
nextAttemptAt = now.plusMinutes(5),
)
outboxEventRepository.saveAllAndFlush(listOf(dueFailed, backoffPendingFailed))
val events = outboxEventRepository.findPublishableForUpdateSkipLocked(
eventType = "REQUEST_TERMINAL_STATUS",
now = now,
maxAttempts = 10,
batchSize = 10,
)
assertEquals(listOf(dueFailed.id), events.map { it.id })
}
@Test
@Transactional
fun `publishable query excludes events that exhausted max attempts`() {
val now = LocalDateTime.parse("2026-06-06T00:00:00")
val retriable = outboxEvent(
requestId = UUID.fromString("cccccccc-0000-4000-8000-000000000003"),
status = OutboxEventStatus.FAILED,
attempts = 9,
)
val exhausted = outboxEvent(
requestId = UUID.fromString("dddddddd-0000-4000-8000-000000000004"),
status = OutboxEventStatus.FAILED,
attempts = 10,
)
outboxEventRepository.saveAllAndFlush(listOf(retriable, exhausted))
val events = outboxEventRepository.findPublishableForUpdateSkipLocked(
eventType = "REQUEST_TERMINAL_STATUS",
now = now,
maxAttempts = 10,
batchSize = 10,
)
assertEquals(listOf(retriable.id), events.map { it.id })
}
private fun outboxEvent(
requestId: UUID,
eventType: String = "REQUEST_COMPLETED",
eventType: String = "REQUEST_TERMINAL_STATUS",
status: OutboxEventStatus = OutboxEventStatus.NEW,
createdAt: LocalDateTime = LocalDateTime.parse("2026-01-01T10:00:00"),
attempts: Int = 0,
nextAttemptAt: LocalDateTime = createdAt,
): OutboxEventEntity {
return OutboxEventEntity(
id = UUID.randomUUID(),
@@ -104,6 +162,8 @@ class OutboxEventRepositoryJpaTest @Autowired constructor(
payload = objectMapper.readTree("""{"requestId":"$requestId","status":"COMPLETED"}"""),
status = status,
createdAt = createdAt,
attempts = attempts,
nextAttemptAt = nextAttemptAt,
)
}
}
@@ -0,0 +1,104 @@
package org.nstart.dep265.requestservice.repository
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.nstart.dep265.requestservice.PcpRequestServiceApplication
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.entity.RequestSurveyType
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.util.UUID
import kotlin.test.assertEquals
@SpringBootTest(
classes = [PcpRequestServiceApplication::class],
webEnvironment = SpringBootTest.WebEnvironment.NONE,
properties = [
"spring.config.import=",
"spring.cloud.config.enabled=false",
"spring.flyway.enabled=false",
"spring.jpa.hibernate.ddl-auto=create-drop",
"spring.datasource.url=jdbc:h2:mem:request-expiry;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
"spring.kafka.listener.auto-startup=false",
"spring.kafka.consumer.group-id=request-service-test",
"app.kafka.topics.route=request-service-test-route",
],
)
class RequestRepositoryJpaTest @Autowired constructor(
private val requestRepository: RequestRepository,
) {
private val now: LocalDateTime = LocalDateTime.parse("2026-06-06T12:00:00")
@BeforeEach
fun setUp() {
requestRepository.deleteAll()
}
@Test
@Transactional
fun `expirable query returns only overdue accepted and active requests`() {
val overdueAccepted = request(status = RequestStatus.ACCEPTED, end = now.minusHours(1))
val overdueActive = request(status = RequestStatus.ACTIVE, end = now.minusHours(2))
val activeInWindow = request(status = RequestStatus.ACTIVE, end = now.plusHours(1))
val alreadyCompleted = request(status = RequestStatus.COMPLETED, end = now.minusHours(1))
val alreadyExpired = request(status = RequestStatus.EXPIRED, end = now.minusHours(1))
val deletedOverdue = request(
status = RequestStatus.DELETED,
end = now.minusHours(1),
deletedAt = now.minusMinutes(30),
)
requestRepository.saveAllAndFlush(
listOf(overdueAccepted, overdueActive, activeInWindow, alreadyCompleted, alreadyExpired, deletedOverdue),
)
val expirable = requestRepository.findExpirableForUpdateSkipLocked(now = now, batchSize = 100)
// Только незавершённые (ACCEPTED/ACTIVE) с истёкшим окном и без soft-delete, по возрастанию end.
assertEquals(listOf(overdueActive.id, overdueAccepted.id), expirable.map { it.id })
}
@Test
@Transactional
fun `expirable query honours the batch size limit`() {
val oldest = request(status = RequestStatus.ACTIVE, end = now.minusHours(3))
val middle = request(status = RequestStatus.ACCEPTED, end = now.minusHours(2))
val newest = request(status = RequestStatus.ACTIVE, end = now.minusHours(1))
requestRepository.saveAllAndFlush(listOf(newest, oldest, middle))
val expirable = requestRepository.findExpirableForUpdateSkipLocked(now = now, batchSize = 2)
assertEquals(listOf(oldest.id, middle.id), expirable.map { it.id })
}
private fun request(
status: RequestStatus,
end: LocalDateTime,
begin: LocalDateTime = end.minusDays(1),
deletedAt: LocalDateTime? = null,
): RequestEntity {
val geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))"
return RequestEntity(
id = UUID.randomUUID(),
name = "Тест1",
geometry = geometry,
remainingGeometry = geometry,
geometryArea = 1.0,
remainingArea = 1.0,
importance = 10.0,
beginDateTime = begin,
endDateTime = end,
surveyType = RequestSurveyType.OPTICS,
status = status,
createdAt = begin.minusDays(1),
updatedAt = begin.minusDays(1),
deletedAt = deletedAt,
)
}
}
@@ -6,6 +6,8 @@ import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.doAnswer
@@ -30,13 +32,16 @@ import java.util.concurrent.CompletableFuture
class OutboxPublisherServiceTest {
private val objectMapper = jacksonObjectMapper()
private val properties = OutboxProperties(
requestCompletedTopic = "pcp.request.completed.v1",
requestStatusTopic = "pcp.request.status.v1",
publishBatchSize = 50,
publishFixedDelayMs = 5_000,
maxAttempts = 10,
initialBackoffMs = 1_000,
maxBackoffMs = 60_000,
)
@Test
fun `new request completed event is published and marked as published`() {
fun `new terminal event is published and marked as published`() {
val requestId = UUID.fromString("6cc8ed4b-0db1-4cfb-bef0-d56eed2edce4")
val event = outboxEvent(
requestId = requestId,
@@ -50,7 +55,7 @@ class OutboxPublisherServiceTest {
assertEquals(1, processedCount)
verify(kafkaTemplate).send(
properties.requestCompletedTopic,
properties.requestStatusTopic,
requestId.toString(),
event.payload.toString(),
)
@@ -61,11 +66,12 @@ class OutboxPublisherServiceTest {
}
@Test
fun `failed request completed event is picked for retry`() {
fun `failed event is picked for retry`() {
val requestId = UUID.fromString("9986e096-886a-429e-af7f-3a6178c29b4f")
val event = outboxEvent(
requestId = requestId,
status = OutboxEventStatus.FAILED,
attempts = 1,
errorMessage = "previous failure",
)
val outboxEventRepository = repositoryReturning(listOf(event))
@@ -75,7 +81,7 @@ class OutboxPublisherServiceTest {
service.publishPending()
verify(kafkaTemplate).send(
properties.requestCompletedTopic,
properties.requestStatusTopic,
requestId.toString(),
event.payload.toString(),
)
@@ -95,14 +101,14 @@ class OutboxPublisherServiceTest {
service.publishPending()
verify(kafkaTemplate).send(
properties.requestCompletedTopic,
properties.requestStatusTopic,
requestId.toString(),
objectMapper.readTree(savedPayload).toString(),
)
}
@Test
fun `kafka failure marks event as failed and stores short error message`() {
fun `kafka failure increments attempts, schedules backoff and stores short error message`() {
val requestId = UUID.fromString("116eb4c7-cd6d-4837-8525-21c1d16ecbbb")
val event = outboxEvent(requestId = requestId)
val outboxEventRepository = repositoryReturning(listOf(event))
@@ -113,17 +119,42 @@ class OutboxPublisherServiceTest {
assertEquals(1, processedCount)
assertEquals(OutboxEventStatus.FAILED, event.status)
assertEquals(1, event.attempts)
assertTrue(event.errorMessage!!.contains("broker unavailable"))
assertNull(event.publishedAt)
// backoff: следующая попытка отложена в будущее относительно создания события.
assertTrue(event.nextAttemptAt.isAfter(event.createdAt))
verify(outboxEventRepository).save(event)
}
@Test
fun `event exhausting max attempts becomes terminal and is not rescheduled`() {
val requestId = UUID.fromString("c0ffee00-0000-4000-8000-000000000001")
val event = outboxEvent(
requestId = requestId,
status = OutboxEventStatus.FAILED,
attempts = properties.maxAttempts - 1,
errorMessage = "previous failure",
)
val outboxEventRepository = repositoryReturning(listOf(event))
val kafkaTemplate = kafkaTemplateReturning(failedSend(IllegalStateException("still broken")))
val service = publisherService(outboxEventRepository, kafkaTemplate)
service.publishPending()
assertEquals(OutboxEventStatus.FAILED, event.status)
assertEquals(properties.maxAttempts, event.attempts)
// Исчерпав попытки, событие терминально: backoff не назначается (next_attempt_at не двигаем).
assertEquals(event.createdAt, event.nextAttemptAt)
}
@Test
fun `failed event is not retried repeatedly in same publish pending call`() {
val requestId = UUID.fromString("f31992a5-efae-4d9a-8262-78381da835f7")
val event = outboxEvent(
requestId = requestId,
status = OutboxEventStatus.FAILED,
attempts = 1,
errorMessage = "previous failure",
)
val outboxEventRepository = repositoryReturning(listOf(event))
@@ -134,15 +165,18 @@ class OutboxPublisherServiceTest {
assertEquals(1, processedCount)
verify(kafkaTemplate, times(1)).send(
properties.requestCompletedTopic,
properties.requestStatusTopic,
requestId.toString(),
event.payload.toString(),
)
verify(outboxEventRepository, times(1)).findRequestCompletedPublishableForUpdateSkipLocked(
RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE,
properties.publishBatchSize,
verify(outboxEventRepository, times(1)).findPublishableForUpdateSkipLocked(
anyString(),
anyLocalDateTime(),
anyInt(),
anyInt(),
)
assertEquals(OutboxEventStatus.FAILED, event.status)
assertEquals(2, event.attempts)
assertTrue(event.errorMessage!!.contains("still broken"))
}
@@ -153,6 +187,7 @@ class OutboxPublisherServiceTest {
val failedEvent = outboxEvent(
requestId = failedRequestId,
status = OutboxEventStatus.FAILED,
attempts = 1,
errorMessage = "previous failure",
)
val newEvent = outboxEvent(requestId = newRequestId)
@@ -164,12 +199,12 @@ class OutboxPublisherServiceTest {
assertEquals(2, processedCount)
verify(kafkaTemplate).send(
properties.requestCompletedTopic,
properties.requestStatusTopic,
failedRequestId.toString(),
failedEvent.payload.toString(),
)
verify(kafkaTemplate).send(
properties.requestCompletedTopic,
properties.requestStatusTopic,
newRequestId.toString(),
newEvent.payload.toString(),
)
@@ -206,9 +241,11 @@ class OutboxPublisherServiceTest {
val outboxEventRepository = mock(OutboxEventRepository::class.java)
doReturn(events)
.`when`(outboxEventRepository)
.findRequestCompletedPublishableForUpdateSkipLocked(
RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE,
properties.publishBatchSize,
.findPublishableForUpdateSkipLocked(
anyString(),
anyLocalDateTime(),
anyInt(),
anyInt(),
)
return outboxEventRepository
}
@@ -256,20 +293,31 @@ class OutboxPublisherServiceTest {
requestId: UUID,
payload: String = """{"requestId":"$requestId","status":"COMPLETED"}""",
status: OutboxEventStatus = OutboxEventStatus.NEW,
attempts: Int = 0,
errorMessage: String? = null,
): OutboxEventEntity {
val createdAt = LocalDateTime.parse("2026-01-01T10:00:00")
return OutboxEventEntity(
id = UUID.randomUUID(),
eventType = RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE,
aggregateType = RequestCompletedOutboxService.REQUEST_AGGREGATE_TYPE,
eventType = RequestStatusOutboxService.REQUEST_STATUS_EVENT_TYPE,
aggregateType = RequestStatusOutboxService.REQUEST_AGGREGATE_TYPE,
aggregateId = requestId,
payload = objectMapper.readTree(payload),
status = status,
createdAt = LocalDateTime.parse("2026-01-01T10:00:00"),
createdAt = createdAt,
attempts = attempts,
nextAttemptAt = createdAt,
errorMessage = errorMessage,
)
}
// Регистрирует matcher, но возвращает non-null заглушку (any() на non-null LocalDateTime в Kotlin
// вернул бы null и сломал стек матчеров Mockito).
private fun anyLocalDateTime(): LocalDateTime {
any(LocalDateTime::class.java)
return LocalDateTime.now()
}
private class ImmediateTransactionOperations : TransactionOperations {
override fun <T : Any?> execute(action: TransactionCallback<T>): T {
return action.doInTransaction(SimpleTransactionStatus())
@@ -1,65 +0,0 @@
package org.nstart.dep265.requestservice.service
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.mockingDetails
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.entity.RequestSurveyType
import org.nstart.dep265.requestservice.repository.OutboxEventRepository
import java.time.LocalDateTime
import java.util.UUID
class RequestCompletedOutboxServiceTest {
private val objectMapper = jacksonObjectMapper()
.registerModule(JavaTimeModule())
.findAndRegisterModules()
@Test
fun `request completed payload contains required fields`() {
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val service = RequestCompletedOutboxService(
outboxEventRepository = outboxEventRepository,
objectMapper = objectMapper,
geometryService = GeometryService(),
)
val requestId = UUID.fromString("5f57cd22-44a7-4fe8-956f-7fd0c52df846")
val completedAt = LocalDateTime.parse("2026-01-01T10:15:30")
val matchedAt = LocalDateTime.parse("2026-01-01T10:14:00")
val eventCreatedAt = LocalDateTime.parse("2026-01-01T10:16:00")
val request = RequestEntity(
id = requestId,
name = "Тест1",
geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))",
remainingGeometry = "POLYGON EMPTY",
geometryArea = 1.0,
remainingArea = 0.0,
importance = 10.0,
beginDateTime = LocalDateTime.parse("2026-01-01T10:00:00"),
endDateTime = LocalDateTime.parse("2026-01-01T11:00:00"),
surveyType = RequestSurveyType.OPTICS,
status = RequestStatus.COMPLETED,
lastMatchedAt = matchedAt,
completedAt = completedAt,
createdAt = LocalDateTime.parse("2026-01-01T09:00:00"),
updatedAt = completedAt,
)
service.createRequestCompletedIfAbsent(request, eventCreatedAt)
val insertInvocation = mockingDetails(outboxEventRepository).invocations.single {
invocation -> invocation.method.name == "insertIfAbsent"
}
val payload = objectMapper.readTree(insertInvocation.arguments[4] as String)
assertEquals(requestId.toString(), payload["requestId"].asText())
assertEquals("COMPLETED", payload["status"].asText())
assertNotNull(payload["completedAt"])
assertEquals(100.0, payload["coveragePercent"].asDouble())
assertNotNull(payload["matchedAt"])
assertNotNull(payload["eventCreatedAt"])
}
}
@@ -0,0 +1,203 @@
package org.nstart.dep265.requestservice.service
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import org.mockito.Mockito.mockingDetails
import org.mockito.Mockito.never
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoInteractions
import org.nstart.dep265.requestservice.config.RequestExpiryProperties
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.entity.RequestSurveyType
import org.nstart.dep265.requestservice.repository.OutboxEventRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import org.springframework.transaction.TransactionStatus
import org.springframework.transaction.support.SimpleTransactionStatus
import org.springframework.transaction.support.TransactionCallback
import org.springframework.transaction.support.TransactionOperations
import java.time.LocalDateTime
import java.util.UUID
class RequestExpiryServiceTest {
@Test
fun `expires overdue accepted and active requests and emits terminal events`() {
val requestRepository = mock(RequestRepository::class.java)
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val service = expiryService(requestRepository, outboxEventRepository, RequestExpiryProperties(batchSize = 200))
val expiredAccepted = overdueRequest(status = RequestStatus.ACCEPTED)
val expiredActive = overdueRequest(status = RequestStatus.ACTIVE)
doReturn(listOf(expiredAccepted, expiredActive))
.`when`(requestRepository)
.findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt())
val expiredCount = service.expireOverdue()
assertEquals(2, expiredCount)
assertEquals(RequestStatus.EXPIRED, expiredAccepted.status)
assertEquals(RequestStatus.EXPIRED, expiredActive.status)
verify(requestRepository).save(expiredAccepted)
verify(requestRepository).save(expiredActive)
// По каждой истёкшей заявке — терминальное событие в outbox для geoportal.
val terminalInserts = mockingDetails(outboxEventRepository).invocations
.filter { invocation -> invocation.method.name == "insertIfAbsent" }
assertEquals(2, terminalInserts.size)
assertTrue(terminalInserts.all { invocation -> invocation.arguments[1] == "REQUEST_TERMINAL_STATUS" })
}
@Test
fun `keeps fetching while a full batch is returned`() {
val requestRepository = mock(RequestRepository::class.java)
val service = expiryService(
requestRepository,
mock(OutboxEventRepository::class.java),
RequestExpiryProperties(batchSize = 1),
)
val first = overdueRequest(status = RequestStatus.ACTIVE)
val second = overdueRequest(status = RequestStatus.ACCEPTED)
doReturn(listOf(first), listOf(second), emptyList<RequestEntity>())
.`when`(requestRepository)
.findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt())
val expiredCount = service.expireOverdue()
assertEquals(2, expiredCount)
verify(requestRepository, times(3))
.findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt())
}
@Test
fun `empty sweep returns zero and emits nothing`() {
val requestRepository = mock(RequestRepository::class.java)
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val service = expiryService(requestRepository, outboxEventRepository, RequestExpiryProperties(batchSize = 200))
doReturn(emptyList<RequestEntity>())
.`when`(requestRepository)
.findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt())
val expiredCount = service.expireOverdue()
assertEquals(0, expiredCount)
verify(requestRepository, never()).save(any(RequestEntity::class.java))
verifyNoInteractions(outboxEventRepository)
}
@Test
fun `request still inside its window is not expired`() {
val requestRepository = mock(RequestRepository::class.java)
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val service = expiryService(requestRepository, outboxEventRepository, RequestExpiryProperties(batchSize = 200))
val now = LocalDateTime.now()
val inWindow = request(
status = RequestStatus.ACTIVE,
beginDateTime = now.minusDays(1),
endDateTime = now.plusDays(1),
)
doReturn(listOf(inWindow))
.`when`(requestRepository)
.findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt())
val expiredCount = service.expireOverdue()
assertEquals(0, expiredCount)
assertEquals(RequestStatus.ACTIVE, inWindow.status)
verify(requestRepository, never()).save(any(RequestEntity::class.java))
verifyNoInteractions(outboxEventRepository)
}
@Test
fun `disabled scheduler does not sweep`() {
val requestRepository = mock(RequestRepository::class.java)
val service = expiryService(
requestRepository,
mock(OutboxEventRepository::class.java),
RequestExpiryProperties(enabled = false),
)
service.expireOverdueScheduledBatch()
verify(requestRepository, never())
.findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt())
}
private fun expiryService(
requestRepository: RequestRepository,
outboxEventRepository: OutboxEventRepository,
properties: RequestExpiryProperties,
): RequestExpiryService {
val geometryService = GeometryService()
return RequestExpiryService(
requestRepository = requestRepository,
requestLifecycleService = RequestLifecycleService(geometryService),
requestStatusOutboxService = RequestStatusOutboxService(
outboxEventRepository = outboxEventRepository,
objectMapper = jacksonObjectMapper()
.registerModule(JavaTimeModule())
.findAndRegisterModules(),
geometryService = geometryService,
),
requestExpiryProperties = properties,
transactionOperations = ImmediateTransactionOperations(),
)
}
private fun overdueRequest(status: RequestStatus): RequestEntity {
val now = LocalDateTime.now()
return request(
status = status,
beginDateTime = now.minusDays(10),
endDateTime = now.minusDays(1),
)
}
private fun request(
status: RequestStatus,
beginDateTime: LocalDateTime,
endDateTime: LocalDateTime,
): RequestEntity {
val geometry = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"
return RequestEntity(
id = UUID.randomUUID(),
name = "Тест1",
geometry = geometry,
remainingGeometry = geometry,
geometryArea = 1.0,
remainingArea = 1.0,
importance = 10.0,
beginDateTime = beginDateTime,
endDateTime = endDateTime,
surveyType = RequestSurveyType.OPTICS,
status = status,
createdAt = beginDateTime.minusDays(1),
updatedAt = beginDateTime.minusDays(1),
)
}
// Регистрирует matcher, но возвращает non-null заглушку — иначе any() на non-null LocalDateTime
// в Kotlin отдаёт null и ломает стек матчеров Mockito.
private fun anyLocalDateTime(): LocalDateTime {
any(LocalDateTime::class.java)
return LocalDateTime.now()
}
private class ImmediateTransactionOperations : TransactionOperations {
override fun <T> execute(action: TransactionCallback<T>): T {
val status: TransactionStatus = SimpleTransactionStatus()
return action.doInTransaction(status)
}
}
}
@@ -50,6 +50,7 @@ class RequestServiceTest {
requestCellRepository = requestCellRepository,
geometryService = GeometryService(),
requestGridProjectionService = requestGridProjectionService,
requestLifecycleService = RequestLifecycleService(GeometryService()),
entityManager = entityManager,
)
@@ -95,6 +96,22 @@ class RequestServiceTest {
verify(requestGridProjectionService).rebuildProjection(persistedRequests.single())
}
@Test
fun `create request passes through explicit coverage required percent from api`() {
val persistedRequests = mutableListOf<RequestEntity>()
doReturn(false)
.`when`(requestRepository)
.existsById(REQUEST_ID)
doAnswer { invocation ->
persistedRequests += invocation.getArgument<RequestEntity>(0)
null
}.`when`(entityManager).persist(any(RequestEntity::class.java))
service.createRequest(createRequestDto(coverageRequiredPercent = 80.0))
assertEquals(80.0, persistedRequests.single().coverageRequiredPercent)
}
@Test
fun `get request by id returns name and importance`() {
val request = existingRequest()
@@ -279,7 +296,7 @@ class RequestServiceTest {
verify(requestCellRepository).deleteByRequestId(REQUEST_ID)
}
private fun createRequestDto(): CreateRequestDto {
private fun createRequestDto(coverageRequiredPercent: Double = 100.0): CreateRequestDto {
return CreateRequestDto(
id = REQUEST_ID,
name = REQUEST_NAME,
@@ -287,6 +304,7 @@ class RequestServiceTest {
importance = REQUEST_IMPORTANCE,
beginDateTime = LocalDateTime.of(2026, 1, 1, 0, 0, 0),
endDateTime = LocalDateTime.of(2026, 1, 31, 23, 59, 59),
coverageRequiredPercent = coverageRequiredPercent,
optics = OpticsParamsDto(
resultType = OpticsResultTypeDto.PANCHROMATIC,
resolution = 1.0,
@@ -0,0 +1,107 @@
package org.nstart.dep265.requestservice.service
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
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.Mockito.mock
import org.mockito.Mockito.mockingDetails
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.entity.RequestSurveyType
import org.nstart.dep265.requestservice.repository.OutboxEventRepository
import java.time.LocalDateTime
import java.util.UUID
class RequestStatusOutboxServiceTest {
private val objectMapper = jacksonObjectMapper()
.registerModule(JavaTimeModule())
.findAndRegisterModules()
@Test
fun `completed terminal payload contains required fields`() {
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val service = service(outboxEventRepository)
val requestId = UUID.fromString("5f57cd22-44a7-4fe8-956f-7fd0c52df846")
val completedAt = LocalDateTime.parse("2026-01-01T10:15:30")
val matchedAt = LocalDateTime.parse("2026-01-01T10:14:00")
val eventCreatedAt = LocalDateTime.parse("2026-01-01T10:16:00")
val request = RequestEntity(
id = requestId,
name = "Тест1",
geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))",
remainingGeometry = "POLYGON EMPTY",
geometryArea = 1.0,
remainingArea = 0.0,
importance = 10.0,
beginDateTime = LocalDateTime.parse("2026-01-01T10:00:00"),
endDateTime = LocalDateTime.parse("2026-01-01T11:00:00"),
surveyType = RequestSurveyType.OPTICS,
status = RequestStatus.COMPLETED,
lastMatchedAt = matchedAt,
completedAt = completedAt,
createdAt = LocalDateTime.parse("2026-01-01T09:00:00"),
updatedAt = completedAt,
)
service.createTerminalIfAbsent(request, eventCreatedAt)
val payload = objectMapper.readTree(insertedPayload(outboxEventRepository))
assertEquals(requestId.toString(), payload["requestId"].asText())
assertEquals("COMPLETED", payload["status"].asText())
assertEquals(completedAt, objectMapper.treeToValue(payload["occurredAt"], LocalDateTime::class.java))
assertEquals(100.0, payload["coveragePercent"].asDouble())
assertNotNull(payload["matchedAt"])
assertNotNull(payload["eventCreatedAt"])
}
@Test
fun `expired terminal payload carries expired status and partial coverage`() {
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val service = service(outboxEventRepository)
val requestId = UUID.fromString("9c2f0c4b-3a51-4e2d-9d8a-1f4b2c6e7a01")
val eventCreatedAt = LocalDateTime.parse("2026-02-01T00:00:00")
val request = RequestEntity(
id = requestId,
name = "Тест1",
geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))",
remainingGeometry = "POLYGON((0 0, 0.5 0, 0.5 1, 0 1, 0 0))",
geometryArea = 1.0,
remainingArea = 0.5,
importance = 10.0,
beginDateTime = LocalDateTime.parse("2026-01-01T00:00:00"),
endDateTime = LocalDateTime.parse("2026-01-31T23:59:59"),
surveyType = RequestSurveyType.OPTICS,
status = RequestStatus.EXPIRED,
completedAt = null,
createdAt = LocalDateTime.parse("2025-12-31T00:00:00"),
updatedAt = eventCreatedAt,
)
service.createTerminalIfAbsent(request, eventCreatedAt)
val payload = objectMapper.readTree(insertedPayload(outboxEventRepository))
assertEquals("EXPIRED", payload["status"].asText())
// completedAt отсутствует у EXPIRED → occurredAt = момент фиксации истечения.
assertEquals(eventCreatedAt, objectMapper.treeToValue(payload["occurredAt"], LocalDateTime::class.java))
assertEquals(50.0, payload["coveragePercent"].asDouble())
assertTrue(payload["matchedAt"].isNull)
}
private fun service(outboxEventRepository: OutboxEventRepository): RequestStatusOutboxService {
return RequestStatusOutboxService(
outboxEventRepository = outboxEventRepository,
objectMapper = objectMapper,
geometryService = GeometryService(),
)
}
private fun insertedPayload(outboxEventRepository: OutboxEventRepository): String {
val insertInvocation = mockingDetails(outboxEventRepository).invocations.single { invocation ->
invocation.method.name == "insertIfAbsent"
}
return insertInvocation.arguments[4] as String
}
}
@@ -288,7 +288,7 @@ class RouteMatchingServiceTest {
invocation -> invocation.method.name == "insertIfAbsent"
}
assertEquals(1, insertInvocations.size)
assertEquals("REQUEST_COMPLETED", insertInvocations.single().arguments[1])
assertEquals("REQUEST_TERMINAL_STATUS", insertInvocations.single().arguments[1])
assertEquals(request.id, insertInvocations.single().arguments[3])
assertEquals(RequestStatus.COMPLETED, request.status)
}
@@ -305,16 +305,16 @@ class RouteMatchingServiceTest {
requestValidationService = RequestValidationService(geometryService),
geometryService = geometryService,
requestLifecycleService = RequestLifecycleService(geometryService),
requestCompletedOutboxService = requestCompletedOutboxService(outboxEventRepository, geometryService),
requestStatusOutboxService = requestStatusOutboxService(outboxEventRepository, geometryService),
routeMatchingTransactionOperations = ImmediateTransactionOperations(),
)
}
private fun requestCompletedOutboxService(
private fun requestStatusOutboxService(
outboxEventRepository: OutboxEventRepository,
geometryService: GeometryService,
): RequestCompletedOutboxService {
return RequestCompletedOutboxService(
): RequestStatusOutboxService {
return RequestStatusOutboxService(
outboxEventRepository = outboxEventRepository,
objectMapper = jacksonObjectMapper()
.registerModule(JavaTimeModule())