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:
+3
@@ -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
|
||||
|
||||
+175
@@ -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>
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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": {
|
||||
|
||||
+25
@@ -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
|
||||
|
||||
+63
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+104
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+67
-19
@@ -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())
|
||||
|
||||
-65
@@ -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"])
|
||||
}
|
||||
}
|
||||
+203
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -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,
|
||||
|
||||
+107
@@ -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
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user