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,97 @@
package org.nstart.dep265.requestservice
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.readText
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class MigrationSchemaTest {
@Test
fun `request service has a single fresh schema migration`() {
val migrations = migrationDir()
.toFile()
.listFiles { file -> file.isFile && file.name.endsWith(".sql") }
.orEmpty()
.map { it.name }
.sorted()
assertEquals(listOf("V1__create_schema.sql"), migrations)
}
@Test
fun `fresh schema defines mandatory cell importance columns`() {
val schema = migration("V1__create_schema.sql").normalizedSql()
val requests = schema.tableBlock("requests")
val earthCells = schema.tableBlock("earth_cells")
val requestCells = schema.tableBlock("request_cells")
assertTrue(requests.contains("name varchar(255) not null"))
assertTrue(requests.contains("constraint chk_requests_name_not_blank check (length(trim(name)) > 0)"))
assertTrue(earthCells.contains("importance double precision not null default 0.0"))
assertTrue(requestCells.contains("importance double precision not null"))
assertTrue(!requestCells.contains("importance double precision not null default"))
}
@Test
fun `earth cell importance trigger writes numeric zero when no request cells remain`() {
val schema = migration("V1__create_schema.sql").normalizedSql()
assertTrue(schema.contains("after insert or update or delete on request_cells"))
assertTrue(schema.contains("coalesce(sum(request_cell.importance * (coalesce(request_cell.coverage_percent, 0.0) / 100.0)), 0.0)"))
}
@Test
fun `outbox schema stores payload as jsonb and deduplicates request completion events`() {
val schema = migration("V1__create_schema.sql").normalizedSql()
val outboxEvents = schema.tableBlock("outbox_events")
assertTrue(outboxEvents.contains("payload jsonb not null"))
assertTrue(outboxEvents.contains("status varchar(32) 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)"))
}
@Test
fun `request route matches schema stores suitability and applied coverage separately`() {
val schema = migration("V1__create_schema.sql").normalizedSql()
val requestRouteMatches = schema.tableBlock("request_route_matches")
assertTrue(requestRouteMatches.contains("original_intersection_geometry text not null"))
assertTrue(requestRouteMatches.contains("original_intersection_area double precision not null"))
assertTrue(requestRouteMatches.contains("applied_intersection_geometry text null"))
assertTrue(requestRouteMatches.contains("applied_intersection_area double precision not null default 0.0"))
assertTrue(requestRouteMatches.contains("coverage_delta_percent double precision not null default 0.0"))
assertTrue(requestRouteMatches.contains("contributes_to_coverage boolean not null default false"))
assertTrue(requestRouteMatches.contains("constraint uk_request_route_match unique (request_id, route_id)"))
}
private fun migration(fileName: String): Path {
val modulePath = Path.of("src/main/resources/db/migration", fileName)
if (Files.exists(modulePath)) {
return modulePath
}
return Path.of("services/pcp-request-service/src/main/resources/db/migration", fileName)
}
private fun migrationDir(): Path {
val modulePath = Path.of("src/main/resources/db/migration")
if (Files.exists(modulePath)) {
return modulePath
}
return Path.of("services/pcp-request-service/src/main/resources/db/migration")
}
private fun Path.normalizedSql(): String =
readText()
.lowercase()
.replace(Regex("\\s+"), " ")
private fun String.tableBlock(tableName: String): String {
val start = indexOf("create table if not exists $tableName")
require(start >= 0) { "Table $tableName is not defined" }
val nextTable = indexOf("create table if not exists", start + 1).takeIf { it >= 0 } ?: length
return substring(start, nextTable)
}
}
@@ -0,0 +1,208 @@
package org.nstart.dep265.requestservice.controller
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.isNull
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository
import org.nstart.dep265.requestservice.service.CellsQueryService
import org.nstart.dep265.requestservice.service.CompatGeometryService
import org.nstart.dep265.requestservice.service.EarthGridCatalogService
import org.nstart.dep265.requestservice.service.GeometryService
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.http.MediaType
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import java.util.Optional
import java.util.UUID
class CellsControllerTest {
private val requestId = UUID.fromString("00000000-0000-0000-0000-000000000301")
private val cellWithRequest = EarthCellEntity(
cellNum = 42,
latitude = 55.0,
longitude = 37.0,
importance = 10.0,
contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))",
).also { earthCell ->
earthCell.requestProjections = mutableListOf(
RequestCellEntity(
requestId = requestId,
contour = "POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))",
coveragePercent = 25.0,
importance = 7.0,
cell = earthCell,
)
)
}
private val earthCellRepository = mock(EarthCellRepository::class.java).also { repository ->
doReturn(
PageImpl(
listOf(cellWithRequest),
PageRequest.of(0, 50),
1,
)
)
.`when`(repository)
.findCells(isNull(), pageableMatcher())
doReturn(
listOf(
cellWithRequest,
EarthCellEntity(
cellNum = 43,
latitude = 54.0,
longitude = 38.0,
importance = 0.0,
contour = "POLYGON ((37 54, 38 54, 38 55, 37 55, 37 54))",
)
)
)
.`when`(repository)
.findCellsWithRequestProjections(isNull())
}
private val earthGridSettingsRepository = mock(EarthGridSettingsRepository::class.java).also { repository ->
doReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0)))
.`when`(repository)
.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
}
private val earthGridCatalogService = EarthGridCatalogService(
earthCellRepository = earthCellRepository,
earthGridSettingsRepository = earthGridSettingsRepository,
compatGeometryService = CompatGeometryService(GeometryService()),
gridEnabled = false,
)
private val mockMvc = MockMvcBuilders
.standaloneSetup(CellsController(CellsQueryService(earthCellRepository, earthGridCatalogService)))
.setControllerAdvice(CellsApiExceptionHandler())
.setMessageConverters(MappingJackson2HttpMessageConverter(jacksonObjectMapper()))
.build()
@Test
fun `get v1 cells returns pageable list response`() {
mockMvc.perform(get("/v1/cells").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.items").isArray)
.andExpect(jsonPath("$.items[0].cellNum").value(42))
.andExpect(jsonPath("$.items[0].latitude").value(55.0))
.andExpect(jsonPath("$.items[0].longitude").value(37.0))
.andExpect(jsonPath("$.items[0].importance").value(10.0))
.andExpect(jsonPath("$.items[0].contour").value("POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))"))
.andExpect(jsonPath("$.page").value(0))
.andExpect(jsonPath("$.size").value(50))
.andExpect(jsonPath("$.totalItems").value(1))
.andExpect(jsonPath("$.totalPages").value(1))
}
@Test
fun `get v1 cells rejects unsupported sort field`() {
mockMvc.perform(
get("/v1/cells")
.queryParam("sort", "geometry,asc")
.accept(MediaType.APPLICATION_JSON),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
.andExpect(jsonPath("$.message").value("Unsupported sort field: geometry"))
}
@Test
fun `get v1 cells rejects countLat before aggregation implementation`() {
mockMvc.perform(
get("/v1/cells")
.queryParam("countLat", "2")
.accept(MediaType.APPLICATION_JSON),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
.andExpect(jsonPath("$.message").value("Cell aggregation by countLat/countLong is not implemented yet"))
}
@Test
fun `get v1 cells with requests returns request fragments`() {
mockMvc.perform(get("/v1/cells/with-requests").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.items").isArray)
.andExpect(jsonPath("$.items[0].cellNum").value(42))
.andExpect(jsonPath("$.items[0].latitude").value(55.0))
.andExpect(jsonPath("$.items[0].longitude").value(37.0))
.andExpect(jsonPath("$.items[0].importance").value(10.0))
.andExpect(jsonPath("$.items[0].contour").value("POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))"))
.andExpect(jsonPath("$.items[0].requests[0].requestId").value(requestId.toString()))
.andExpect(jsonPath("$.items[0].requests[0].contour").value("POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))"))
.andExpect(jsonPath("$.items[0].requests[0].coveragePercent").value(25.0))
.andExpect(jsonPath("$.items[0].requests[0].importance").value(7.0))
.andExpect(jsonPath("$.items[1].requests").isEmpty)
}
@Test
fun `get v1 cells with requests rejects partial aggregation parameters`() {
mockMvc.perform(
get("/v1/cells/with-requests")
.queryParam("countLat", "2")
.accept(MediaType.APPLICATION_JSON),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
.andExpect(jsonPath("$.message").value("countLat and countLong must be provided together"))
}
@Test
fun `get v1 cells with requests rejects invalid numeric parameters`() {
mockMvc.perform(
get("/v1/cells/with-requests")
.queryParam("minImportance", "-1")
.accept(MediaType.APPLICATION_JSON),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
.andExpect(jsonPath("$.message").value("minImportance must be greater than or equal to 0"))
mockMvc.perform(
get("/v1/cells/with-requests")
.queryParam("countLat", "0")
.queryParam("countLong", "1")
.accept(MediaType.APPLICATION_JSON),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
.andExpect(jsonPath("$.message").value("countLat must be greater than 0"))
}
@Test
fun `get v1 cells with requests accepts complete aggregation parameters`() {
mockMvc.perform(
get("/v1/cells/with-requests")
.queryParam("countLat", "1")
.queryParam("countLong", "1")
.accept(MediaType.APPLICATION_JSON),
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.items").isArray)
}
@Test
fun `legacy earth grid with requests endpoint is not restored`() {
mockMvc.perform(get("/api/earth-grid/with-requests").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound)
}
private fun pageableMatcher(): Pageable {
any(Pageable::class.java)
return PageRequest.of(0, 50)
}
}
@@ -0,0 +1,127 @@
package org.nstart.dep265.requestservice.controller
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
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.doThrow
import org.mockito.Mockito.mock
import org.nstart.dep265.requestservice.dto.GridRebuildResponseDto
import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity
import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository
import org.nstart.dep265.requestservice.service.GridRebuildAlreadyRunningException
import org.nstart.dep265.requestservice.service.GridRebuildService
import org.nstart.dep265.requestservice.service.GridSettingsService
import org.springframework.http.MediaType
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import java.time.OffsetDateTime
import java.util.Optional
class GridManagementControllerTest {
private val earthGridSettingsRepository = mock(EarthGridSettingsRepository::class.java).also { repository ->
doAnswer { invocation -> invocation.getArgument<EarthGridSettingsEntity>(0) }
.`when`(repository)
.save(any(EarthGridSettingsEntity::class.java))
}
private val gridRebuildService = mock(GridRebuildService::class.java)
private val mockMvc = MockMvcBuilders
.standaloneSetup(GridManagementController(GridSettingsService(earthGridSettingsRepository), gridRebuildService))
.setControllerAdvice(GridManagementApiExceptionHandler())
.setMessageConverters(MappingJackson2HttpMessageConverter(jacksonObjectMapper().findAndRegisterModules()))
.build()
@Test
fun `get v1 grid settings returns current settings`() {
doReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 4.0)))
.`when`(earthGridSettingsRepository)
.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
mockMvc.perform(get("/v1/grid/settings").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.settingsId").value(1))
.andExpect(jsonPath("$.calculationStep").value(4.0))
}
@Test
fun `get v1 grid settings creates and returns default settings when missing`() {
doReturn(Optional.empty<EarthGridSettingsEntity>())
.`when`(earthGridSettingsRepository)
.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
mockMvc.perform(get("/v1/grid/settings").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.settingsId").value(1))
.andExpect(jsonPath("$.calculationStep").value(2.0))
}
@Test
fun `put v1 grid settings updates settings`() {
doReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0)))
.`when`(earthGridSettingsRepository)
.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
mockMvc.perform(
put("/v1/grid/settings")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content("""{"calculationStep": 3.5}"""),
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.settingsId").value(1))
.andExpect(jsonPath("$.calculationStep").value(3.5))
}
@Test
fun `put v1 grid settings rejects invalid settings request`() {
mockMvc.perform(
put("/v1/grid/settings")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content("""{"calculationStep": 0.0}"""),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
}
@Test
fun `post v1 grid rebuild returns rebuild result`() {
doReturn(
GridRebuildResponseDto(
cellsCount = 2,
rebuiltRequestProjectionsCount = 2,
rebuiltAt = OffsetDateTime.parse("2026-05-20T10:15:30Z"),
)
).`when`(gridRebuildService).rebuildGrid()
mockMvc.perform(post("/v1/grid/rebuild").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.cellsCount").value(2))
.andExpect(jsonPath("$.rebuiltRequestProjectionsCount").value(2))
.andExpect(jsonPath("$.rebuiltAt").value("2026-05-20T10:15:30Z"))
}
@Test
fun `post v1 grid rebuild returns conflict when lock is busy`() {
doThrow(GridRebuildAlreadyRunningException())
.`when`(gridRebuildService)
.rebuildGrid()
mockMvc.perform(post("/v1/grid/rebuild").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isConflict)
.andExpect(jsonPath("$.code").value("GRID_REBUILD_ALREADY_RUNNING"))
}
@Test
fun `legacy earth grid endpoint is not restored`() {
mockMvc.perform(get("/api/earth-grid").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound)
}
}
@@ -0,0 +1,508 @@
package org.nstart.dep265.requestservice.controller
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import jakarta.persistence.EntityManager
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.ArgumentMatchers.isNull
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.entity.RequestOpticsParamsEntity
import org.nstart.dep265.requestservice.entity.RequestRsaParamsEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.entity.RequestSurveyType
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestOpticsParamsRepository
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.RequestService
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.http.MediaType
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import java.time.LocalDateTime
import java.util.Optional
import java.util.UUID
class RequestControllerOpenApiSkeletonTest {
private val requestRepository = mock(RequestRepository::class.java).also { repository ->
doReturn(Optional.empty<RequestEntity>())
.`when`(repository)
.findById(any())
doReturn(PageImpl(emptyList<RequestEntity>(), PageRequest.of(0, 50), 0))
.`when`(repository)
.findRequests(
anyBoolean(),
deletedStatusMatcher(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
pageableMatcher(),
)
doAnswer { invocation -> invocation.getArgument<RequestEntity>(0) }
.`when`(repository)
.save(any(RequestEntity::class.java))
}
private val requestOpticsParamsRepository = mock(RequestOpticsParamsRepository::class.java).also { repository ->
doAnswer { invocation -> invocation.getArgument<RequestOpticsParamsEntity>(0) }
.`when`(repository)
.save(any(RequestOpticsParamsEntity::class.java))
doReturn(Optional.empty<RequestOpticsParamsEntity>())
.`when`(repository)
.findById(any())
}
private val requestRsaParamsRepository = mock(RequestRsaParamsRepository::class.java).also { repository ->
doAnswer { invocation -> invocation.getArgument<RequestRsaParamsEntity>(0) }
.`when`(repository)
.save(any(RequestRsaParamsEntity::class.java))
doReturn(Optional.empty<RequestRsaParamsEntity>())
.`when`(repository)
.findById(any())
}
private val requestCellRepository = mock(RequestCellRepository::class.java).also { repository ->
doReturn(emptyList<RequestCellEntity>())
.`when`(repository)
.findWithCellByRequestId(uuidMatcher())
}
private val entityManager = mock(EntityManager::class.java)
private val mockMvc = MockMvcBuilders
.standaloneSetup(
RequestController(
RequestService(
requestRepository = requestRepository,
requestOpticsParamsRepository = requestOpticsParamsRepository,
requestRsaParamsRepository = requestRsaParamsRepository,
requestCellRepository = requestCellRepository,
geometryService = GeometryService(),
requestGridProjectionService = mock(RequestGridProjectionService::class.java),
entityManager = entityManager,
)
)
)
.setControllerAdvice(RequestApiExceptionHandler())
.setMessageConverters(
MappingJackson2HttpMessageConverter(
jacksonObjectMapper()
.registerModule(JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS),
),
)
.build()
@Test
fun `post v1 requests returns create response`() {
mockMvc.perform(
post("/v1/requests")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(
"""
{
"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:00Z",
"endDateTime": "2026-01-31T23:59:59Z",
"kpp": [1, 2, 3],
"highPriorityTransmit": false,
"optics": {
"resultType": "PANCHROMATIC",
"resolution": 1.0
}
}
""".trimIndent(),
),
)
.andExpect(status().isCreated)
.andExpect(jsonPath("$.id").value("3fa85f64-5717-4562-b3fc-2c963f66afa6"))
.andExpect(jsonPath("$.name").value("Тест1"))
.andExpect(jsonPath("$.status").value("ACCEPTED"))
.andExpect(jsonPath("$.surveyType").value("OPTICS"))
.andExpect(jsonPath("$.importance").value(10.0))
}
@Test
fun `post v1 requests returns conflict when request id already exists`() {
val id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6")
doReturn(true)
.`when`(requestRepository)
.existsById(id)
mockMvc.perform(
post("/v1/requests")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(
"""
{
"id": "$id",
"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",
"optics": {
"resultType": "PANCHROMATIC",
"resolution": 1.0
}
}
""".trimIndent(),
),
)
.andExpect(status().isConflict)
.andExpect(jsonPath("$.code").value("REQUEST_ALREADY_EXISTS"))
}
@Test
fun `post v1 requests rejects missing name`() {
mockMvc.perform(
post("/v1/requests")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(
"""
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"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",
"optics": {
"resultType": "PANCHROMATIC",
"resolution": 1.0
}
}
""".trimIndent(),
),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
}
@Test
fun `post v1 requests rejects blank name`() {
mockMvc.perform(
post("/v1/requests")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(
"""
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": " ",
"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",
"optics": {
"resultType": "PANCHROMATIC",
"resolution": 1.0
}
}
""".trimIndent(),
),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
}
@Test
fun `post v1 requests rejects missing importance`() {
mockMvc.perform(
post("/v1/requests")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(
"""
{
"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))",
"beginDateTime": "2026-01-01T00:00:00Z",
"endDateTime": "2026-01-31T23:59:59Z",
"optics": {
"resultType": "PANCHROMATIC",
"resolution": 1.0
}
}
""".trimIndent(),
),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
}
@Test
fun `post v1 requests rejects negative importance`() {
mockMvc.perform(
post("/v1/requests")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(
"""
{
"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": -1.0,
"beginDateTime": "2026-01-01T00:00:00Z",
"endDateTime": "2026-01-31T23:59:59Z",
"optics": {
"resultType": "PANCHROMATIC",
"resolution": 1.0
}
}
""".trimIndent(),
),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
}
@Test
fun `post v1 requests rejects missing optics and rsa`() {
mockMvc.perform(
post("/v1/requests")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(
"""
{
"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:00Z",
"endDateTime": "2026-01-31T23:59:59Z"
}
""".trimIndent(),
),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
}
@Test
fun `get v1 requests returns list response`() {
mockMvc.perform(
get("/v1/requests")
.queryParam("page", "2")
.queryParam("size", "25")
.accept(MediaType.APPLICATION_JSON),
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.items").isArray)
.andExpect(jsonPath("$.page").value(2))
.andExpect(jsonPath("$.size").value(25))
.andExpect(jsonPath("$.totalItems").value(0))
.andExpect(jsonPath("$.totalPages").value(0))
}
@Test
fun `get v1 requests returns importance in summary`() {
doReturn(PageImpl(listOf(existingRequest())))
.`when`(requestRepository)
.findRequests(
anyBoolean(),
deletedStatusMatcher(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
pageableMatcher(),
)
mockMvc.perform(get("/v1/requests").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.items[0].name").value("Тест1"))
.andExpect(jsonPath("$.items[0].importance").value(10.0))
}
@Test
fun `get v1 requests rejects unsupported sort field`() {
mockMvc.perform(
get("/v1/requests")
.queryParam("sort", "geometry,asc")
.accept(MediaType.APPLICATION_JSON),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
}
@Test
fun `get v1 requests by id returns importance`() {
val id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6")
doReturn(Optional.of(existingRequest()))
.`when`(requestRepository)
.findById(id)
mockMvc.perform(get("/v1/requests/$id").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.name").value("Тест1"))
.andExpect(jsonPath("$.importance").value(10.0))
}
@Test
fun `get v1 request with cells returns request and cell projection fields`() {
val id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6")
val earthCell = EarthCellEntity(
cellId = 1,
cellNum = 32400,
latitude = 55.0,
longitude = 37.0,
importance = 5.0,
contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))",
)
doReturn(Optional.of(existingRequest()))
.`when`(requestRepository)
.findById(id)
doReturn(
listOf(
RequestCellEntity(
requestId = id,
coveragePercent = 25.0,
importance = 7.0,
contour = "POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))",
cell = earthCell,
)
)
)
.`when`(requestCellRepository)
.findWithCellByRequestId(id)
mockMvc.perform(get("/v1/requests/$id/with-cells").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.request.id").value(id.toString()))
.andExpect(jsonPath("$.request.name").value("Тест1"))
.andExpect(jsonPath("$.request.importance").value(10.0))
.andExpect(jsonPath("$.request.geometry").value("POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))"))
.andExpect(jsonPath("$.request.coverage.currentPercent").value(50.0))
.andExpect(jsonPath("$.cells[0].cellNum").value(32400))
.andExpect(jsonPath("$.cells[0].coveragePercent").value(25.0))
.andExpect(jsonPath("$.cells[0].importance").value(7.0))
.andExpect(jsonPath("$.cells[0].contour").value("POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))"))
}
@Test
fun `get v1 request with cells returns empty cells for request without projections`() {
val id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6")
doReturn(Optional.of(existingRequest()))
.`when`(requestRepository)
.findById(id)
doReturn(emptyList<RequestCellEntity>())
.`when`(requestCellRepository)
.findWithCellByRequestId(id)
mockMvc.perform(get("/v1/requests/$id/with-cells").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.request.id").value(id.toString()))
.andExpect(jsonPath("$.cells").isEmpty)
}
@Test
fun `get v1 request with cells returns deleted request with empty cells`() {
val id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6")
val request = existingRequest().also { existingRequest ->
existingRequest.status = RequestStatus.DELETED
existingRequest.deletedAt = LocalDateTime.parse("2026-02-01T00:00:00")
}
doReturn(Optional.of(request))
.`when`(requestRepository)
.findById(id)
doReturn(emptyList<RequestCellEntity>())
.`when`(requestCellRepository)
.findWithCellByRequestId(id)
mockMvc.perform(get("/v1/requests/$id/with-cells").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.request.status").value("DELETED"))
.andExpect(jsonPath("$.request.deletedAt").exists())
.andExpect(jsonPath("$.cells").isEmpty)
}
@Test
fun `get and delete by id return not found when request is absent`() {
val id = "3fa85f64-5717-4562-b3fc-2c963f66afa6"
mockMvc.perform(get("/v1/requests/$id").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound)
mockMvc.perform(get("/v1/requests/$id/with-cells").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound)
mockMvc.perform(delete("/v1/requests/$id").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound)
}
@Test
fun `legacy request with cells endpoint is not restored`() {
mockMvc.perform(
get("/api/requests/by-number/3fa85f64-5717-4562-b3fc-2c963f66afa6/with-cells")
.accept(MediaType.APPLICATION_JSON),
)
.andExpect(status().isNotFound)
}
private fun existingRequest(): 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.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"),
name = "Тест1",
geometry = geometry,
remainingGeometry = geometry,
geometryArea = 1.0,
remainingArea = 0.5,
coverageRequiredPercent = 100.0,
importance = 10.0,
beginDateTime = LocalDateTime.parse("2026-01-01T00:00:00"),
endDateTime = LocalDateTime.parse("2026-01-31T23:59:59"),
highPriorityTransmit = false,
surveyType = RequestSurveyType.OPTICS,
status = RequestStatus.ACTIVE,
createdAt = LocalDateTime.parse("2025-12-31T00:00:00"),
updatedAt = LocalDateTime.parse("2025-12-31T00:00:00"),
)
}
private fun deletedStatusMatcher(): RequestStatus {
any(RequestStatus::class.java)
return RequestStatus.DELETED
}
private fun pageableMatcher(): Pageable {
any(Pageable::class.java)
return PageRequest.of(0, 50)
}
private fun uuidMatcher(): UUID {
any(UUID::class.java)
return UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6")
}
}
@@ -0,0 +1,126 @@
package org.nstart.dep265.requestservice.dto
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import java.time.OffsetDateTime
import java.util.UUID
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
class RequestApiDtoContractTest {
private val objectMapper = jacksonObjectMapper()
.registerModule(JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
@Test
fun `create request dto matches openapi input fields`() {
val request = objectMapper.readValue<CreateRequestRequestDto>(
"""
{
"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:00Z",
"endDateTime": "2026-01-31T23:59:59Z",
"kpp": [1, 2, 3],
"highPriorityTransmit": false,
"optics": {
"resultType": "PANCHROMATIC",
"resolution": 1.0,
"sunAngleMin": 10.0,
"sunAngleMax": 45.0,
"clouds": 90.0
}
}
""".trimIndent(),
)
assertEquals(UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), request.id)
assertEquals("Тест1", request.name)
assertEquals(10.0, request.importance)
assertEquals(OffsetDateTime.parse("2026-01-01T00:00:00Z"), request.beginDateTime)
assertEquals(OpticsResultTypeDto.PANCHROMATIC, request.optics?.resultType)
}
@Test
fun `create request dto rejects survey type as input field`() {
val payloadWithSurveyType = """
{
"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:00Z",
"endDateTime": "2026-01-31T23:59:59Z",
"surveyType": "OPTICS",
"optics": {
"resultType": "PANCHROMATIC",
"resolution": 1.0
}
}
""".trimIndent()
assertFailsWith<Exception> {
objectMapper.readValue<CreateRequestRequestDto>(payloadWithSurveyType)
}
}
@Test
fun `create request serialization does not expose survey type`() {
val json = objectMapper.writeValueAsString(
CreateRequestRequestDto(
id = UUID.fromString("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 = OffsetDateTime.parse("2026-01-01T00:00:00Z"),
endDateTime = OffsetDateTime.parse("2026-01-31T23:59:59Z"),
optics = OpticsParamsDto(
resultType = OpticsResultTypeDto.PANCHROMATIC,
resolution = 1.0,
),
),
)
assertFalse(json.contains("surveyType"))
assertFalse(json.contains("app_type"))
assertFalse(json.contains("appType"))
assertFalse(json.contains("AppType"))
assertFalse(json.contains("requestType"))
}
@Test
fun `cells list response dto matches openapi fields`() {
val json = objectMapper.writeValueAsString(
CellsListResponseDto(
items = listOf(
CellSummaryResponseDto(
cellNum = 42,
latitude = 55.0,
longitude = 37.0,
importance = 10.0,
contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))",
)
),
page = 0,
size = 50,
totalItems = 1,
totalPages = 1,
)
)
assertEquals(
setOf("items", "page", "size", "totalItems", "totalPages"),
objectMapper.readTree(json).fieldNames().asSequence().toSet(),
)
assertEquals(
setOf("cellNum", "latitude", "longitude", "importance", "contour"),
objectMapper.readTree(json)["items"][0].fieldNames().asSequence().toSet(),
)
}
}
@@ -0,0 +1,46 @@
package org.nstart.dep265.requestservice.mapper
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto
import java.math.BigDecimal
import java.time.LocalDateTime
import java.util.UUID
class RoutePassportMapperTest {
private val mapper = RoutePassportMapper()
@Test
fun `should convert route roll angle to absolute range`() {
val 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("-5.0"),
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 route = mapper.toRouteDto(routePassportDto)
assertEquals(0.0, route.rollAngle.min)
assertEquals(12.0, route.rollAngle.max)
}
}
@@ -0,0 +1,109 @@
package org.nstart.dep265.requestservice.repository
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.nstart.dep265.requestservice.PcpRequestServiceApplication
import org.nstart.dep265.requestservice.entity.OutboxEventEntity
import org.nstart.dep265.requestservice.entity.OutboxEventStatus
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
@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:outbox-events;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 OutboxEventRepositoryJpaTest @Autowired constructor(
private val outboxEventRepository: OutboxEventRepository,
) {
private val objectMapper = jacksonObjectMapper()
@BeforeEach
fun setUp() {
outboxEventRepository.deleteAll()
}
@Test
fun `unique constraint prevents duplicate request completed event for same request`() {
val requestId = UUID.fromString("73da563f-448b-4303-a1df-41d618d3cb6d")
outboxEventRepository.saveAndFlush(outboxEvent(requestId = requestId))
assertFailsWith<DataIntegrityViolationException> {
outboxEventRepository.saveAndFlush(outboxEvent(requestId = requestId))
}
}
@Test
@Transactional
fun `publishable query returns new events before failed and excludes published events`() {
val oldFailedEvent = outboxEvent(
requestId = UUID.fromString("58c9fb48-3930-4c5d-8486-4385130c4706"),
createdAt = LocalDateTime.parse("2026-01-01T09:00:00"),
status = OutboxEventStatus.FAILED,
)
val newEvent = outboxEvent(
requestId = UUID.fromString("85cb5fb8-9666-4705-bf02-1376a5d74975"),
createdAt = LocalDateTime.parse("2026-01-01T10:00:00"),
status = OutboxEventStatus.NEW,
)
val newerFailedEvent = outboxEvent(
requestId = UUID.fromString("62faf96d-8bd5-46ba-a541-c10e78851f86"),
createdAt = LocalDateTime.parse("2026-01-01T10:01:00"),
status = OutboxEventStatus.FAILED,
)
val publishedEvent = outboxEvent(
requestId = UUID.fromString("75051f40-9e67-4f72-9279-b4c64da409d9"),
status = OutboxEventStatus.PUBLISHED,
)
val otherEventType = outboxEvent(
requestId = UUID.fromString("d911b183-2ba5-493f-81fa-944366db63b5"),
eventType = "REQUEST_CREATED",
status = OutboxEventStatus.NEW,
)
outboxEventRepository.saveAllAndFlush(listOf(oldFailedEvent, newEvent, newerFailedEvent, publishedEvent, otherEventType))
val events = outboxEventRepository.findRequestCompletedPublishableForUpdateSkipLocked(
eventType = "REQUEST_COMPLETED",
batchSize = 10,
)
assertEquals(listOf(newEvent.id, oldFailedEvent.id, newerFailedEvent.id), events.map { it.id })
}
private fun outboxEvent(
requestId: UUID,
eventType: String = "REQUEST_COMPLETED",
status: OutboxEventStatus = OutboxEventStatus.NEW,
createdAt: LocalDateTime = LocalDateTime.parse("2026-01-01T10:00:00"),
): OutboxEventEntity {
return OutboxEventEntity(
id = UUID.randomUUID(),
eventType = eventType,
aggregateType = "REQUEST",
aggregateId = requestId,
payload = objectMapper.readTree("""{"requestId":"$requestId","status":"COMPLETED"}"""),
status = status,
createdAt = createdAt,
)
}
}
@@ -0,0 +1,293 @@
package org.nstart.dep265.requestservice.service
import org.junit.jupiter.api.Test
import org.nstart.dep265.requestservice.PcpRequestServiceApplication
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.transaction.annotation.Transactional
import java.util.UUID
import jakarta.persistence.EntityManager
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:cells-query;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",
],
)
@Transactional
class CellsQueryServiceJpaTest @Autowired constructor(
private val earthCellRepository: EarthCellRepository,
private val requestCellRepository: RequestCellRepository,
private val entityManager: EntityManager,
private val service: CellsQueryService,
) {
@Test
fun `list cells without min importance returns all cells paged`() {
saveCell(cellNum = 1, importance = 3.0)
saveCell(cellNum = 2, importance = 2.0)
saveCell(cellNum = 3, importance = 1.0)
val response = service.listCells(
minImportance = null,
countLat = null,
countLong = null,
page = 1,
size = 2,
sort = null,
)
assertEquals(listOf(3L), response.items.map { cell -> cell.cellNum })
assertEquals(1, response.page)
assertEquals(2, response.size)
assertEquals(3, response.totalItems)
assertEquals(2, response.totalPages)
}
@Test
fun `list cells filters by min importance in repository query`() {
saveCell(cellNum = 1, importance = 3.0)
saveCell(cellNum = 2, importance = 1.0)
saveCell(cellNum = 3, importance = 2.0)
val response = service.listCells(
minImportance = 2.0,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = null,
)
assertEquals(listOf(1L), response.items.map { cell -> cell.cellNum })
assertEquals(1, response.totalItems)
}
@Test
fun `list cells uses strict min importance filter and counts only matching cells`() {
saveCell(cellNum = 1, importance = 0.0)
saveCell(cellNum = 2, importance = 0.5)
val response = service.listCells(
minImportance = 0.0,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = null,
)
assertEquals(listOf(2L), response.items.map { cell -> cell.cellNum })
assertEquals(1, response.totalItems)
assertEquals(1, response.totalPages)
}
@Test
fun `list cells excludes cells equal to legacy min importance threshold`() {
saveCell(cellNum = 1, importance = 0.001)
saveCell(cellNum = 2, importance = 0.002)
val response = service.listCells(
minImportance = 0.001,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = null,
)
assertEquals(listOf(2L), response.items.map { cell -> cell.cellNum })
assertEquals(1, response.totalItems)
}
@Test
fun `list cells without min importance includes zero importance cells`() {
saveCell(cellNum = 1, importance = 0.0)
saveCell(cellNum = 2, importance = 0.5)
val response = service.listCells(
minImportance = null,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = null,
)
assertEquals(listOf(2L, 1L), response.items.map { cell -> cell.cellNum })
assertEquals(2, response.totalItems)
}
@Test
fun `list cells uses default importance desc and cell num asc sort`() {
saveCell(cellNum = 3, importance = 5.0)
saveCell(cellNum = 1, importance = 5.0)
saveCell(cellNum = 2, importance = 7.0)
val response = service.listCells(
minImportance = null,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = null,
)
assertEquals(listOf(2L, 1L, 3L), response.items.map { cell -> cell.cellNum })
}
@Test
fun `list cells supports whitelisted sort field`() {
saveCell(cellNum = 1, latitude = 55.0)
saveCell(cellNum = 2, latitude = 53.0)
val response = service.listCells(
minImportance = null,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = "latitude,asc",
)
assertEquals(listOf(2L, 1L), response.items.map { cell -> cell.cellNum })
}
@Test
fun `list cells returns response fields matching openapi schema`() {
saveCell(
cellNum = 42,
latitude = 55.0,
longitude = 37.0,
importance = 10.0,
contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))",
)
val response = service.listCells(
minImportance = null,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = null,
)
val cell = response.items.single()
assertEquals(42L, cell.cellNum)
assertEquals(55.0, cell.latitude)
assertEquals(37.0, cell.longitude)
assertEquals(10.0, cell.importance)
assertEquals("POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))", cell.contour)
}
@Test
fun `list cells with requests returns all cells and request fragment fields`() {
val requestId = UUID.fromString("00000000-0000-0000-0000-000000000201")
val cellWithRequest = saveCell(cellNum = 10, importance = 5.0)
saveCell(cellNum = 11, importance = 0.0)
requestCellRepository.saveAndFlush(
RequestCellEntity(
requestId = requestId,
coveragePercent = 33.0,
importance = 7.0,
contour = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))",
cell = cellWithRequest,
)
)
entityManager.clear()
val response = service.listCellsWithRequests(
minImportance = null,
countLat = null,
countLong = null,
)
assertEquals(listOf(10L, 11L), response.items.map { cell -> cell.cellNum })
assertEquals(emptyList(), response.items[1].requests)
val fragment = response.items.first().requests.single()
assertEquals(requestId, fragment.requestId)
assertEquals("POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))", fragment.contour)
assertEquals(33.0, fragment.coveragePercent)
assertEquals(7.0, fragment.importance)
}
@Test
fun `list cells with requests filters by earth cell importance`() {
saveCell(cellNum = 10, importance = 5.0)
saveCell(cellNum = 11, importance = 2.0)
entityManager.clear()
val response = service.listCellsWithRequests(
minImportance = 2.0,
countLat = null,
countLong = null,
)
assertEquals(listOf(10L), response.items.map { cell -> cell.cellNum })
}
@Test
fun `list cells with requests uses strict min importance filter`() {
saveCell(cellNum = 10, importance = 0.0)
saveCell(cellNum = 11, importance = 1.0)
entityManager.clear()
val response = service.listCellsWithRequests(
minImportance = 0.0,
countLat = null,
countLong = null,
)
assertEquals(listOf(11L), response.items.map { cell -> cell.cellNum })
}
@Test
fun `list cells with requests excludes cells equal to legacy min importance threshold`() {
saveCell(cellNum = 10, importance = 0.001)
saveCell(cellNum = 11, importance = 0.002)
entityManager.clear()
val response = service.listCellsWithRequests(
minImportance = 0.001,
countLat = null,
countLong = null,
)
assertEquals(listOf(11L), response.items.map { cell -> cell.cellNum })
}
private fun saveCell(
cellNum: Long,
latitude: Double = 55.0,
longitude: Double = 37.0,
importance: Double = 0.0,
contour: String = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))",
): EarthCellEntity {
return earthCellRepository.saveAndFlush(
EarthCellEntity(
cellNum = cellNum,
latitude = latitude,
longitude = longitude,
importance = importance,
contour = contour,
)
)
}
}
@@ -0,0 +1,258 @@
package org.nstart.dep265.requestservice.service
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.isNull
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 org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import java.util.Optional
import java.util.UUID
import kotlin.test.assertEquals
class CellsQueryServiceTest {
@Test
fun `list cells uses repository pageable query and does not call find all`() {
val earthCellRepository = emptyCellsRepository()
val service = service(earthCellRepository)
service.listCells(
minImportance = null,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = null,
)
verify(earthCellRepository).findCells(isNull(), pageableMatcher())
verify(earthCellRepository, never()).findAll()
}
@Test
fun `list cells builds default sort`() {
val capturedPageables = mutableListOf<Pageable>()
val earthCellRepository = emptyCellsRepository(capturedPageables)
val service = service(earthCellRepository)
service.listCells(
minImportance = null,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = null,
)
val sort = capturedPageables.single().sort.toList()
assertEquals("importance", sort[0].property)
assertEquals(false, sort[0].isAscending)
assertEquals("cellNum", sort[1].property)
assertEquals(true, sort[1].isAscending)
}
@Test
fun `list cells rejects invalid query values`() {
val service = service(emptyCellsRepository())
assertThrows<InvalidCellsQueryException> {
service.listCells(
minImportance = -1.0,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = null,
)
}
assertThrows<InvalidCellsQueryException> {
service.listCells(
minImportance = null,
countLat = null,
countLong = null,
page = -1,
size = 50,
sort = null,
)
}
assertThrows<InvalidCellsQueryException> {
service.listCells(
minImportance = null,
countLat = null,
countLong = null,
page = 0,
size = 501,
sort = null,
)
}
}
@Test
fun `list cells with requests returns fragments from request cells`() {
val requestId = UUID.fromString("00000000-0000-0000-0000-000000000101")
val cellWithRequest = cell(cellNum = 10, importance = 5.0)
cellWithRequest.requestProjections = mutableListOf(
RequestCellEntity(
requestId = requestId,
contour = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))",
coveragePercent = 25.0,
importance = 7.0,
cell = cellWithRequest,
)
)
val cellWithoutRequests = cell(cellNum = 11, importance = 0.0)
val earthCellRepository = emptyCellsRepository().also { repository ->
doReturn(listOf(cellWithRequest, cellWithoutRequests))
.`when`(repository)
.findCellsWithRequestProjections(isNull())
}
val service = service(earthCellRepository)
val response = service.listCellsWithRequests(
minImportance = null,
countLat = null,
countLong = null,
)
assertEquals(listOf(10L, 11L), response.items.map { cell -> cell.cellNum })
assertEquals(emptyList(), response.items[1].requests)
val fragment = response.items.first().requests.single()
assertEquals(requestId, fragment.requestId)
assertEquals("POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))", fragment.contour)
assertEquals(25.0, fragment.coveragePercent)
assertEquals(7.0, fragment.importance)
verify(earthCellRepository).findCellsWithRequestProjections(isNull())
verify(earthCellRepository, never()).findCells(isNull(), pageableMatcher())
verify(earthCellRepository, never()).findAll()
}
@Test
fun `list cells with requests passes min importance to repository`() {
val earthCellRepository = emptyCellsRepository().also { repository ->
doReturn(listOf(cell(cellNum = 10, importance = 5.0)))
.`when`(repository)
.findCellsWithRequestProjections(2.0)
}
val service = service(earthCellRepository)
val response = service.listCellsWithRequests(
minImportance = 2.0,
countLat = null,
countLong = null,
)
assertEquals(listOf(10L), response.items.map { cell -> cell.cellNum })
verify(earthCellRepository).findCellsWithRequestProjections(2.0)
}
@Test
fun `list cells with requests rejects partial aggregation parameters`() {
val service = service(emptyCellsRepository())
assertThrows<InvalidCellsQueryException> {
service.listCellsWithRequests(
minImportance = null,
countLat = 2,
countLong = null,
)
}
assertThrows<InvalidCellsQueryException> {
service.listCellsWithRequests(
minImportance = null,
countLat = null,
countLong = 2,
)
}
}
@Test
fun `list cells with requests delegates to existing aggregation when both counts are present`() {
val earthCellRepository = emptyCellsRepository().also { repository ->
doReturn(
listOf(
cell(cellNum = 10, latitude = -88.0, longitude = 0.0, importance = 1.0),
cell(cellNum = 11, latitude = -88.0, longitude = 2.0, importance = 2.0),
)
)
.`when`(repository)
.findCellsWithRequestProjections(isNull())
}
val service = service(earthCellRepository)
val response = service.listCellsWithRequests(
minImportance = null,
countLat = 1,
countLong = 2,
)
assertEquals(1, response.items.size)
assertEquals(10L, response.items.first().cellNum)
assertEquals(3.0, response.items.first().importance)
assertEquals("POLYGON ((-2.0 -90.0, 2.0 -90.0, 2.0 -88.0, -2.0 -88.0, -2.0 -90.0))", response.items.first().contour)
}
private fun emptyCellsRepository(capturedPageables: MutableList<Pageable> = mutableListOf()): EarthCellRepository {
return mock(EarthCellRepository::class.java).also { repository ->
doAnswer { invocation ->
capturedPageables += invocation.getArgument<Pageable>(1)
PageImpl(emptyList<EarthCellEntity>(), PageRequest.of(0, 50), 0)
}
.`when`(repository)
.findCells(isNull(), pageableMatcher())
doReturn(emptyList<EarthCellEntity>())
.`when`(repository)
.findCellsWithRequestProjections(isNull())
}
}
private fun service(earthCellRepository: EarthCellRepository): CellsQueryService {
val earthGridSettingsRepository = mock(EarthGridSettingsRepository::class.java).also { repository ->
doReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0)))
.`when`(repository)
.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
}
val earthGridCatalogService = EarthGridCatalogService(
earthCellRepository = earthCellRepository,
earthGridSettingsRepository = earthGridSettingsRepository,
compatGeometryService = CompatGeometryService(GeometryService()),
gridEnabled = false,
)
return CellsQueryService(earthCellRepository, earthGridCatalogService)
}
private fun cell(
cellNum: Long,
latitude: Double = 55.0,
longitude: Double = 37.0,
importance: Double = 0.0,
contour: String = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))",
): EarthCellEntity {
return EarthCellEntity(
cellNum = cellNum,
latitude = latitude,
longitude = longitude,
importance = importance,
contour = contour,
)
}
private fun pageableMatcher(): Pageable {
any(Pageable::class.java)
return PageRequest.of(0, 50)
}
}
@@ -0,0 +1,177 @@
package org.nstart.dep265.requestservice.service
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.anyList
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoInteractions
import org.mockito.Mockito.`when`
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository
import java.util.Optional
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class EarthGridCatalogServiceTest {
private val earthCellRepository = mock(EarthCellRepository::class.java)
private val earthGridSettingsRepository = mock(EarthGridSettingsRepository::class.java)
private val geometryService = GeometryService()
private val compatGeometryService = CompatGeometryService(geometryService)
@Test
fun `init stores requested calculation step`() {
val currentSettings = EarthGridSettingsEntity(calculationStep = 2.0)
`when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID))
.thenReturn(Optional.of(currentSettings))
val service = service(gridEnabled = false)
service.init(3.5)
val settingsCaptor = ArgumentCaptor.forClass(EarthGridSettingsEntity::class.java)
verify(earthGridSettingsRepository).save(settingsCaptor.capture())
verifyNoInteractions(earthCellRepository)
assertEquals(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, settingsCaptor.value.settingsId)
assertEquals(3.5, settingsCaptor.value.calculationStep)
}
@Test
fun `init creates settings row when it is missing`() {
`when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID))
.thenReturn(Optional.empty())
val service = service(gridEnabled = false)
service.init(1.0)
val settingsCaptor = ArgumentCaptor.forClass(EarthGridSettingsEntity::class.java)
verify(earthGridSettingsRepository).save(settingsCaptor.capture())
assertEquals(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, settingsCaptor.value.settingsId)
assertEquals(1.0, settingsCaptor.value.calculationStep)
}
@Test
fun `init creates earth cells with zero non-null importance`() {
`when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID))
.thenReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0)))
val savedCells = mutableListOf<EarthCellEntity>()
org.mockito.Mockito.doAnswer { invocation ->
val cells = invocation.getArgument<List<EarthCellEntity>>(0)
savedCells += cells
cells
}.`when`(earthCellRepository).saveAll(anyList())
val service = service(gridEnabled = true)
service.init(180.0)
assertTrue(savedCells.isNotEmpty())
assertTrue(savedCells.all { earthCell -> earthCell.importance == 0.0 })
}
@Test
fun `by importance returns source cells when aggregation params are absent`() {
`when`(earthCellRepository.findAllByImportanceGreaterThanOrderByImportanceDesc(0.0))
.thenReturn(listOf(cell(id = 1L, num = 10L, latitude = -88.0, longitude = 0.0, importance = 2.0)))
val service = service(gridEnabled = false)
val cells = service.byImportance(0.0)
assertEquals(1, cells.size)
assertEquals(10L, cells.first().num)
assertEquals(2.0, cells.first().importance)
}
@Test
fun `by importance aggregates neighbor cells using stored calculation step`() {
`when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID))
.thenReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0)))
`when`(earthCellRepository.findAllByImportanceGreaterThanOrderByImportanceDesc(0.0))
.thenReturn(
listOf(
cell(id = 1L, num = 10L, latitude = -88.0, longitude = 0.0, importance = 1.0),
cell(id = 2L, num = 11L, latitude = -88.0, longitude = 2.0, importance = 2.0),
cell(id = 3L, num = 12L, latitude = -86.0, longitude = 0.0, importance = 3.0),
cell(id = 4L, num = 13L, latitude = -86.0, longitude = 2.0, importance = 4.0),
)
)
val service = service(gridEnabled = false)
val cells = service.byImportance(0.0, countLat = 2, countLong = 2)
assertEquals(1, cells.size)
assertEquals(10.0, cells.first().importance)
assertEquals(-88.0, cells.first().latitude)
assertEquals(0.0, cells.first().longitude)
assertEquals(
"POLYGON ((-2.0 -90.0, 2.0 -90.0, 2.0 -86.0, -2.0 -86.0, -2.0 -90.0))",
cells.first().contour,
)
}
@Test
fun `with requests aggregates cells and keeps request fragments`() {
`when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID))
.thenReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0)))
`when`(earthCellRepository.findCellsWithRequestProjections(null))
.thenReturn(
listOf(
cellWithRequest(id = 1L, num = 10L, latitude = -88.0, longitude = 0.0, importance = 1.0),
cellWithRequest(id = 2L, num = 11L, latitude = -88.0, longitude = 2.0, importance = 2.0),
)
)
val service = service(gridEnabled = false)
val cells = service.allWithRequests(countLat = 1, countLong = 2)
assertEquals(1, cells.size)
assertEquals(3.0, cells.first().importance)
assertEquals(2, cells.first().requests.count())
}
private fun service(gridEnabled: Boolean) =
EarthGridCatalogService(
earthCellRepository = earthCellRepository,
earthGridSettingsRepository = earthGridSettingsRepository,
compatGeometryService = compatGeometryService,
gridEnabled = gridEnabled,
)
private fun cell(
id: Long,
num: Long,
latitude: Double,
longitude: Double,
importance: Double,
) = EarthCellEntity(
cellId = id,
cellNum = num,
latitude = latitude,
longitude = longitude,
importance = importance,
contour = "",
)
private fun cellWithRequest(
id: Long,
num: Long,
latitude: Double,
longitude: Double,
importance: Double,
): EarthCellEntity {
val earthCell = cell(id, num, latitude, longitude, importance)
earthCell.requestProjections = mutableListOf(
RequestCellEntity(
id = id,
requestId = UUID.fromString("00000000-0000-0000-0000-${id.toString().padStart(12, '0')}"),
contour = "POLYGON EMPTY",
importance = importance,
cell = earthCell,
)
)
return earthCell
}
}
@@ -0,0 +1,162 @@
package org.nstart.dep265.requestservice.service
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyCollection
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 org.mockito.Mockito.verifyNoInteractions
import org.mockito.Mockito.inOrder
import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity
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.EarthCellRepository
import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import java.time.LocalDateTime
import java.util.Optional
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class GridRebuildServiceTest {
private val earthGridSettingsRepository = mock(EarthGridSettingsRepository::class.java).also { repository ->
doAnswer { invocation -> invocation.getArgument<EarthGridSettingsEntity>(0) }
.`when`(repository)
.save(org.mockito.ArgumentMatchers.any(EarthGridSettingsEntity::class.java))
}
private val gridSettingsService = GridSettingsService(earthGridSettingsRepository)
private val earthGridCatalogService = mock(EarthGridCatalogService::class.java)
private val requestCellRepository = mock(RequestCellRepository::class.java)
private val requestRepository = mock(RequestRepository::class.java)
private val requestGridProjectionService = mock(RequestGridProjectionService::class.java)
private val earthCellRepository = mock(EarthCellRepository::class.java)
private val gridRebuildLockService = mock(GridRebuildLockService::class.java)
@Test
fun `rebuild grid rebuilds catalog and projections only for active requests`() {
val acceptedRequest = request(status = RequestStatus.ACCEPTED)
val activeRequest = request(status = RequestStatus.ACTIVE)
val completedRequest = request(status = RequestStatus.COMPLETED)
val expiredRequest = request(status = RequestStatus.EXPIRED)
val deletedRequest = request(status = RequestStatus.DELETED)
doReturn(true).`when`(gridRebuildLockService).tryAcquireTransactionLock()
doReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 180.0)))
.`when`(earthGridSettingsRepository)
.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
doReturn(listOf(acceptedRequest, activeRequest))
.`when`(requestRepository)
.findByStatusIn(anyCollection())
doReturn(2L).`when`(earthCellRepository).count()
val response = service().rebuildGrid()
assertEquals(2L, response.cellsCount)
assertEquals(2L, response.rebuiltRequestProjectionsCount)
val order = inOrder(requestCellRepository, earthGridCatalogService, requestGridProjectionService)
order.verify(requestCellRepository).deleteAllInBatch()
order.verify(earthGridCatalogService).init(180.0)
order.verify(requestGridProjectionService).rebuildProjection(acceptedRequest)
order.verify(requestGridProjectionService).rebuildProjection(activeRequest)
verify(requestRepository).findByStatusIn(setOf(RequestStatus.ACCEPTED, RequestStatus.ACTIVE))
verify(requestGridProjectionService, never()).rebuildProjection(completedRequest)
verify(requestGridProjectionService, never()).rebuildProjection(expiredRequest)
verify(requestGridProjectionService, never()).rebuildProjection(deletedRequest)
}
@Test
fun `rebuild grid uses default settings when settings row is missing`() {
doReturn(true).`when`(gridRebuildLockService).tryAcquireTransactionLock()
doReturn(Optional.empty<EarthGridSettingsEntity>())
.`when`(earthGridSettingsRepository)
.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
doReturn(emptyList<RequestEntity>())
.`when`(requestRepository)
.findByStatusIn(anyCollection())
doReturn(0L).`when`(earthCellRepository).count()
val response = service().rebuildGrid()
assertEquals(0L, response.rebuiltRequestProjectionsCount)
verify(earthGridCatalogService).init(EarthGridSettingsEntity.DEFAULT_CALCULATION_STEP)
}
@Test
fun `rebuild grid fails with conflict before side effects when lock is busy`() {
doReturn(false).`when`(gridRebuildLockService).tryAcquireTransactionLock()
assertFailsWith<GridRebuildAlreadyRunningException> {
service().rebuildGrid()
}
verifyNoInteractions(
earthGridSettingsRepository,
earthGridCatalogService,
requestCellRepository,
requestRepository,
requestGridProjectionService,
earthCellRepository,
)
}
@Test
fun `rebuild grid does not mutate request domain fields`() {
val activeRequest = request(status = RequestStatus.ACTIVE)
val remainingGeometry = activeRequest.remainingGeometry
val remainingArea = activeRequest.remainingArea
doReturn(true).`when`(gridRebuildLockService).tryAcquireTransactionLock()
doReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 180.0)))
.`when`(earthGridSettingsRepository)
.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
doReturn(listOf(activeRequest))
.`when`(requestRepository)
.findByStatusIn(anyCollection())
doReturn(2L).`when`(earthCellRepository).count()
service().rebuildGrid()
assertEquals(remainingGeometry, activeRequest.remainingGeometry)
assertEquals(remainingArea, activeRequest.remainingArea)
assertEquals(RequestStatus.ACTIVE, activeRequest.status)
}
private fun service() =
GridRebuildService(
gridSettingsService = gridSettingsService,
earthGridCatalogService = earthGridCatalogService,
requestCellRepository = requestCellRepository,
requestRepository = requestRepository,
requestGridProjectionService = requestGridProjectionService,
earthCellRepository = earthCellRepository,
gridRebuildLockService = gridRebuildLockService,
)
private fun request(status: RequestStatus): RequestEntity {
val geometry = "POLYGON ((0 1, 1 1, 1 0, 0 0, 0 1))"
val now = LocalDateTime.parse("2026-05-20T10:00:00")
return RequestEntity(
id = UUID.randomUUID(),
name = "Тест1",
geometry = geometry,
remainingGeometry = geometry,
geometryArea = 1.0,
remainingArea = 0.5,
importance = 7.0,
beginDateTime = now,
endDateTime = now.plusDays(1),
surveyType = RequestSurveyType.OPTICS,
status = status,
createdAt = now,
updatedAt = now,
deletedAt = if (status == RequestStatus.DELETED) now else null,
)
}
}
@@ -0,0 +1,98 @@
package org.nstart.dep265.requestservice.service
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoInteractions
import org.mockito.Mockito.`when`
import org.nstart.dep265.requestservice.dto.UpdateGridSettingsRequestDto
import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import java.util.Optional
import kotlin.test.assertEquals
class GridSettingsServiceTest {
private val earthGridSettingsRepository = mock(EarthGridSettingsRepository::class.java).also { repository ->
doAnswer { invocation -> invocation.getArgument<EarthGridSettingsEntity>(0) }
.`when`(repository)
.save(any(EarthGridSettingsEntity::class.java))
}
@Test
fun `get settings returns current settings`() {
`when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID))
.thenReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 4.0)))
val service = GridSettingsService(earthGridSettingsRepository)
val response = service.getSettings()
assertEquals(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, response.settingsId)
assertEquals(4.0, response.calculationStep)
verify(earthGridSettingsRepository, never()).save(any(EarthGridSettingsEntity::class.java))
}
@Test
fun `get settings creates and returns default settings when missing`() {
`when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID))
.thenReturn(Optional.empty())
val service = GridSettingsService(earthGridSettingsRepository)
val response = service.getSettings()
val settingsCaptor = ArgumentCaptor.forClass(EarthGridSettingsEntity::class.java)
verify(earthGridSettingsRepository).save(settingsCaptor.capture())
assertEquals(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, settingsCaptor.value.settingsId)
assertEquals(EarthGridSettingsEntity.DEFAULT_CALCULATION_STEP, settingsCaptor.value.calculationStep)
assertEquals(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, response.settingsId)
assertEquals(EarthGridSettingsEntity.DEFAULT_CALCULATION_STEP, response.calculationStep)
}
@Test
fun `put settings updates calculation step`() {
val currentSettings = EarthGridSettingsEntity(calculationStep = 2.0)
`when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID))
.thenReturn(Optional.of(currentSettings))
val service = GridSettingsService(earthGridSettingsRepository)
val response = service.updateSettings(UpdateGridSettingsRequestDto(calculationStep = 3.5))
val settingsCaptor = ArgumentCaptor.forClass(EarthGridSettingsEntity::class.java)
verify(earthGridSettingsRepository).save(settingsCaptor.capture())
assertEquals(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, settingsCaptor.value.settingsId)
assertEquals(3.5, settingsCaptor.value.calculationStep)
assertEquals(3.5, response.calculationStep)
}
@Test
fun `put settings does not rebuild earth cells delete request cells or project requests`() {
val earthCellRepository = mock(EarthCellRepository::class.java)
val requestCellRepository = mock(RequestCellRepository::class.java)
val requestGridProjectionService = mock(RequestGridProjectionService::class.java)
`when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID))
.thenReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0)))
val service = GridSettingsService(earthGridSettingsRepository)
service.updateSettings(UpdateGridSettingsRequestDto(calculationStep = 1.0))
verifyNoInteractions(earthCellRepository)
verifyNoInteractions(requestCellRepository)
verifyNoInteractions(requestGridProjectionService)
}
@Test
fun `put settings rejects non-positive calculation step`() {
val service = GridSettingsService(earthGridSettingsRepository)
assertThrows<InvalidGridSettingsException> {
service.updateSettings(UpdateGridSettingsRequestDto(calculationStep = 0.0))
}
verify(earthGridSettingsRepository, never()).save(any(EarthGridSettingsEntity::class.java))
}
}
@@ -0,0 +1,282 @@
package org.nstart.dep265.requestservice.service
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.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.mock
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoInteractions
import org.nstart.dep265.requestservice.config.OutboxProperties
import org.nstart.dep265.requestservice.entity.OutboxEventEntity
import org.nstart.dep265.requestservice.entity.OutboxEventStatus
import org.nstart.dep265.requestservice.repository.OutboxEventRepository
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.support.SendResult
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
import java.util.concurrent.CompletableFuture
class OutboxPublisherServiceTest {
private val objectMapper = jacksonObjectMapper()
private val properties = OutboxProperties(
requestCompletedTopic = "pcp.request.completed.v1",
publishBatchSize = 50,
publishFixedDelayMs = 5_000,
)
@Test
fun `new request completed event is published and marked as published`() {
val requestId = UUID.fromString("6cc8ed4b-0db1-4cfb-bef0-d56eed2edce4")
val event = outboxEvent(
requestId = requestId,
payload = """{"requestId":"$requestId","status":"COMPLETED","source":"outbox"}""",
)
val outboxEventRepository = repositoryReturning(listOf(event))
val kafkaTemplate = kafkaTemplateReturning(successfulSend())
val service = publisherService(outboxEventRepository, kafkaTemplate)
val processedCount = service.publishPending()
assertEquals(1, processedCount)
verify(kafkaTemplate).send(
properties.requestCompletedTopic,
requestId.toString(),
event.payload.toString(),
)
assertEquals(OutboxEventStatus.PUBLISHED, event.status)
assertNotNull(event.publishedAt)
assertNull(event.errorMessage)
verify(outboxEventRepository).save(event)
}
@Test
fun `failed request completed event is picked for retry`() {
val requestId = UUID.fromString("9986e096-886a-429e-af7f-3a6178c29b4f")
val event = outboxEvent(
requestId = requestId,
status = OutboxEventStatus.FAILED,
errorMessage = "previous failure",
)
val outboxEventRepository = repositoryReturning(listOf(event))
val kafkaTemplate = kafkaTemplateReturning(successfulSend())
val service = publisherService(outboxEventRepository, kafkaTemplate)
service.publishPending()
verify(kafkaTemplate).send(
properties.requestCompletedTopic,
requestId.toString(),
event.payload.toString(),
)
assertEquals(OutboxEventStatus.PUBLISHED, event.status)
assertNull(event.errorMessage)
}
@Test
fun `payload from outbox event is sent without rebuilding it`() {
val requestId = UUID.fromString("e390c558-a09f-41c6-9e45-307c04bb3365")
val savedPayload = """{"requestId":"$requestId","status":"COMPLETED","customField":"kept-from-outbox"}"""
val event = outboxEvent(requestId = requestId, payload = savedPayload)
val outboxEventRepository = repositoryReturning(listOf(event))
val kafkaTemplate = kafkaTemplateReturning(successfulSend())
val service = publisherService(outboxEventRepository, kafkaTemplate)
service.publishPending()
verify(kafkaTemplate).send(
properties.requestCompletedTopic,
requestId.toString(),
objectMapper.readTree(savedPayload).toString(),
)
}
@Test
fun `kafka failure marks event as failed and stores short error message`() {
val requestId = UUID.fromString("116eb4c7-cd6d-4837-8525-21c1d16ecbbb")
val event = outboxEvent(requestId = requestId)
val outboxEventRepository = repositoryReturning(listOf(event))
val kafkaTemplate = kafkaTemplateReturning(failedSend(IllegalStateException("broker unavailable")))
val service = publisherService(outboxEventRepository, kafkaTemplate)
val processedCount = service.publishPending()
assertEquals(1, processedCount)
assertEquals(OutboxEventStatus.FAILED, event.status)
assertTrue(event.errorMessage!!.contains("broker unavailable"))
assertNull(event.publishedAt)
verify(outboxEventRepository).save(event)
}
@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,
errorMessage = "previous failure",
)
val outboxEventRepository = repositoryReturning(listOf(event))
val kafkaTemplate = kafkaTemplateReturning(failedSend(IllegalStateException("still broken")))
val service = publisherService(outboxEventRepository, kafkaTemplate)
val processedCount = service.publishPending()
assertEquals(1, processedCount)
verify(kafkaTemplate, times(1)).send(
properties.requestCompletedTopic,
requestId.toString(),
event.payload.toString(),
)
verify(outboxEventRepository, times(1)).findRequestCompletedPublishableForUpdateSkipLocked(
RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE,
properties.publishBatchSize,
)
assertEquals(OutboxEventStatus.FAILED, event.status)
assertTrue(event.errorMessage!!.contains("still broken"))
}
@Test
fun `failed event does not block other events from same batch`() {
val failedRequestId = UUID.fromString("7000c57f-a41a-4788-8929-42a181ff2135")
val newRequestId = UUID.fromString("e5ca9291-4d59-457c-995f-91c618dbd872")
val failedEvent = outboxEvent(
requestId = failedRequestId,
status = OutboxEventStatus.FAILED,
errorMessage = "previous failure",
)
val newEvent = outboxEvent(requestId = newRequestId)
val outboxEventRepository = repositoryReturning(listOf(newEvent, failedEvent))
val kafkaTemplate = kafkaTemplateFailingForKeys(setOf(failedRequestId.toString()))
val service = publisherService(outboxEventRepository, kafkaTemplate)
val processedCount = service.publishPending()
assertEquals(2, processedCount)
verify(kafkaTemplate).send(
properties.requestCompletedTopic,
failedRequestId.toString(),
failedEvent.payload.toString(),
)
verify(kafkaTemplate).send(
properties.requestCompletedTopic,
newRequestId.toString(),
newEvent.payload.toString(),
)
assertEquals(OutboxEventStatus.FAILED, failedEvent.status)
assertEquals(OutboxEventStatus.PUBLISHED, newEvent.status)
assertNotNull(newEvent.publishedAt)
}
@Test
fun `published event is not published again when repository returns no publishable rows`() {
val outboxEventRepository = repositoryReturning(emptyList())
val kafkaTemplate = kafkaTemplateReturning(successfulSend())
val service = publisherService(outboxEventRepository, kafkaTemplate)
val processedCount = service.publishPending()
assertEquals(0, processedCount)
verifyNoInteractions(kafkaTemplate)
}
private fun publisherService(
outboxEventRepository: OutboxEventRepository,
kafkaTemplate: KafkaTemplate<String, String>,
): OutboxPublisherService {
return OutboxPublisherService(
outboxEventRepository = outboxEventRepository,
kafkaTemplate = kafkaTemplate,
outboxProperties = properties,
transactionOperations = ImmediateTransactionOperations(),
)
}
private fun repositoryReturning(events: List<OutboxEventEntity>): OutboxEventRepository {
val outboxEventRepository = mock(OutboxEventRepository::class.java)
doReturn(events)
.`when`(outboxEventRepository)
.findRequestCompletedPublishableForUpdateSkipLocked(
RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE,
properties.publishBatchSize,
)
return outboxEventRepository
}
@Suppress("UNCHECKED_CAST")
private fun kafkaTemplateReturning(
result: CompletableFuture<SendResult<String, String>>,
): KafkaTemplate<String, String> {
val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate<String, String>
doReturn(result)
.`when`(kafkaTemplate)
.send(anyString(), anyString(), anyString())
return kafkaTemplate
}
@Suppress("UNCHECKED_CAST")
private fun kafkaTemplateFailingForKeys(failedKeys: Set<String>): KafkaTemplate<String, String> {
val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate<String, String>
doAnswer { invocation ->
val key = invocation.arguments[1] as String
if (key in failedKeys) {
failedSend(IllegalStateException("broker unavailable for $key"))
} else {
successfulSend()
}
}
.`when`(kafkaTemplate)
.send(anyString(), anyString(), anyString())
return kafkaTemplate
}
@Suppress("UNCHECKED_CAST")
private fun successfulSend(): CompletableFuture<SendResult<String, String>> {
val sendResult = mock(SendResult::class.java) as SendResult<String, String>
return CompletableFuture.completedFuture(sendResult)
}
private fun failedSend(exception: Exception): CompletableFuture<SendResult<String, String>> {
val future = CompletableFuture<SendResult<String, String>>()
future.completeExceptionally(exception)
return future
}
private fun outboxEvent(
requestId: UUID,
payload: String = """{"requestId":"$requestId","status":"COMPLETED"}""",
status: OutboxEventStatus = OutboxEventStatus.NEW,
errorMessage: String? = null,
): OutboxEventEntity {
return OutboxEventEntity(
id = UUID.randomUUID(),
eventType = RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE,
aggregateType = RequestCompletedOutboxService.REQUEST_AGGREGATE_TYPE,
aggregateId = requestId,
payload = objectMapper.readTree(payload),
status = status,
createdAt = LocalDateTime.parse("2026-01-01T10:00:00"),
errorMessage = errorMessage,
)
}
private class ImmediateTransactionOperations : TransactionOperations {
override fun <T : Any?> execute(action: TransactionCallback<T>): T {
return action.doInTransaction(SimpleTransactionStatus())
}
override fun executeWithoutResult(action: java.util.function.Consumer<TransactionStatus>) {
action.accept(SimpleTransactionStatus())
}
}
}
@@ -0,0 +1,65 @@
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,142 @@
package org.nstart.dep265.requestservice.service
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.mock
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
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.EarthCellRepository
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import java.time.LocalDateTime
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class RequestGridProjectionServiceTest {
private val geometryService = GeometryService()
@Test
fun `binds cells correctly for request in minus180 plus180 format crossing zero meridian`() {
val savedFragments = rebuildProjection(
geometry = "POLYGON ((-1 1, 1 1, 1 -1, -1 -1, -1 1))",
)
assertEquals(setOf(100L, 101L), savedFragments.map { fragment -> fragment.cell?.cellNum }.toSet())
assertFragmentInCellBand(savedFragments, 100L, 359.0, 360.0)
assertFragmentInCellBand(savedFragments, 101L, 0.0, 1.0)
}
@Test
fun `binds cells correctly for request in zero360 format crossing zero meridian`() {
val savedFragments = rebuildProjection(
geometry = "POLYGON ((359 1, 1 1, 1 -1, 359 -1, 359 1))",
)
assertEquals(setOf(100L, 101L), savedFragments.map { fragment -> fragment.cell?.cellNum }.toSet())
assertFragmentInCellBand(savedFragments, 100L, 359.0, 360.0)
assertFragmentInCellBand(savedFragments, 101L, 0.0, 1.0)
}
@Test
fun `request cells inherit request importance and make earth cell importance positive`() {
val savedFragments = rebuildProjection(
geometry = "POLYGON ((0 1, 1 1, 1 -1, 0 -1, 0 1))",
importance = 7.5,
)
assertTrue(savedFragments.isNotEmpty())
assertTrue(savedFragments.all { fragment -> fragment.importance == 7.5 })
val earthCellImportance = savedFragments.sumOf { fragment ->
fragment.importance * ((fragment.coveragePercent ?: 0.0) / 100.0)
}
assertTrue(earthCellImportance > 0.0)
}
private fun rebuildProjection(
geometry: String,
importance: Double = 10.0,
): List<RequestCellEntity> {
val requestRepository = mock(RequestRepository::class.java)
val earthCellRepository = mock(EarthCellRepository::class.java)
val requestCellRepository = mock(RequestCellRepository::class.java)
val savedFragments = mutableListOf<RequestCellEntity>()
val service = RequestGridProjectionService(
requestRepository = requestRepository,
earthCellRepository = earthCellRepository,
requestCellRepository = requestCellRepository,
geometryService = geometryService,
gridEnabled = true,
)
val cells = listOf(
EarthCellEntity(
cellId = 1L,
cellNum = 100L,
contour = "POLYGON ((359 1, 360 1, 360 -1, 359 -1, 359 1))",
),
EarthCellEntity(
cellId = 2L,
cellNum = 101L,
contour = "POLYGON ((0 1, 1 1, 1 -1, 0 -1, 0 1))",
),
)
doAnswer { invocation ->
val polygonWkt = invocation.getArgument<String>(0)
val queryGeometry = geometryService.parsePolygonalGeometry(polygonWkt)
cells.filter { cell ->
val cellGeometry = geometryService.parsePolygonalGeometry(cell.contour)
queryGeometry.intersects(cellGeometry)
}
}.`when`(earthCellRepository).findIntersectingByContour(anyString())
doAnswer { invocation ->
val fragments = invocation.getArgument<List<RequestCellEntity>>(0)
savedFragments += fragments
fragments
}.`when`(requestCellRepository).saveAll(org.mockito.ArgumentMatchers.anyList())
service.rebuildProjection(
RequestEntity(
id = UUID.randomUUID(),
name = "Тест1",
geometry = geometry,
remainingGeometry = geometry,
geometryArea = 1.0,
remainingArea = 1.0,
importance = importance,
beginDateTime = LocalDateTime.now(),
endDateTime = LocalDateTime.now().plusDays(1),
highPriorityTransmit = false,
surveyType = RequestSurveyType.OPTICS,
status = RequestStatus.ACTIVE,
matchCount = 0,
createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now(),
)
)
return savedFragments
}
private fun assertFragmentInCellBand(
savedFragments: List<RequestCellEntity>,
cellNum: Long,
expectedMinX: Double,
expectedMaxX: Double,
) {
val fragment = savedFragments.first { requestCell -> requestCell.cell?.cellNum == cellNum }
val geometry = geometryService.parsePolygonalGeometry(fragment.contour)
assertEquals(expectedMinX, geometry.envelopeInternal.minX)
assertEquals(expectedMaxX, geometry.envelopeInternal.maxX)
assertTrue(geometry.area > 0.0)
}
}
@@ -0,0 +1,255 @@
package org.nstart.dep265.requestservice.service
import jakarta.persistence.EntityManager
import org.junit.jupiter.api.Test
import org.nstart.dep265.requestservice.dto.ListRequestsQueryDto
import org.nstart.dep265.requestservice.dto.RequestStatusDto
import org.nstart.dep265.requestservice.dto.SurveyTypeDto
import org.nstart.dep265.requestservice.PcpRequestServiceApplication
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.entity.RequestSurveyType
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestOpticsParamsRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import org.nstart.dep265.requestservice.repository.RequestRsaParamsRepository
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.time.OffsetDateTime
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-list;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",
],
)
@Transactional
class RequestServiceListRequestsJpaTest @Autowired constructor(
private val earthCellRepository: EarthCellRepository,
private val requestRepository: RequestRepository,
private val service: RequestService,
@Suppress("unused") private val requestOpticsParamsRepository: RequestOpticsParamsRepository,
@Suppress("unused") private val requestRsaParamsRepository: RequestRsaParamsRepository,
@Suppress("unused") private val requestCellRepository: RequestCellRepository,
@Suppress("unused") private val entityManager: EntityManager,
) {
@Test
fun `list requests excludes deleted by default`() {
val activeId = saveRequest(status = RequestStatus.ACTIVE, createdAt = "2026-01-02T00:00:00")
saveRequest(status = RequestStatus.DELETED, createdAt = "2026-01-03T00:00:00")
val response = service.listRequests(ListRequestsQueryDto())
assertEquals(listOf(activeId), response.items.map { request -> request.id })
assertEquals("Тест1", response.items.single().name)
assertEquals(1, response.totalItems)
assertEquals(1, response.totalPages)
}
@Test
fun `list requests includes deleted when includeDeleted is true`() {
val activeId = saveRequest(status = RequestStatus.ACTIVE, createdAt = "2026-01-02T00:00:00")
val deletedId = saveRequest(status = RequestStatus.DELETED, createdAt = "2026-01-03T00:00:00")
val response = service.listRequests(ListRequestsQueryDto(includeDeleted = true))
assertEquals(listOf(deletedId, activeId), response.items.map { request -> request.id })
assertEquals(2, response.totalItems)
}
@Test
fun `list requests filters by status`() {
val activeId = saveRequest(status = RequestStatus.ACTIVE)
saveRequest(status = RequestStatus.ACCEPTED)
val response = service.listRequests(ListRequestsQueryDto(status = RequestStatusDto.ACTIVE))
assertEquals(listOf(activeId), response.items.map { request -> request.id })
}
@Test
fun `list requests filters by survey type`() {
val rsaId = saveRequest(surveyType = RequestSurveyType.RSA)
saveRequest(surveyType = RequestSurveyType.OPTICS)
val response = service.listRequests(ListRequestsQueryDto(surveyType = SurveyTypeDto.RSA))
assertEquals(listOf(rsaId), response.items.map { request -> request.id })
}
@Test
fun `list requests filters by kpp in database collection`() {
val matchedId = saveRequest(kpp = mutableListOf(1, 2))
saveRequest(kpp = mutableListOf(3, 4))
val response = service.listRequests(ListRequestsQueryDto(kpp = 2))
assertEquals(listOf(matchedId), response.items.map { request -> request.id })
}
@Test
fun `list requests filters by high priority transmit`() {
val highPriorityId = saveRequest(highPriorityTransmit = true)
saveRequest(highPriorityTransmit = false)
val response = service.listRequests(ListRequestsQueryDto(highPriorityTransmit = true))
assertEquals(listOf(highPriorityId), response.items.map { request -> request.id })
}
@Test
fun `list requests filters by begin date range inclusively`() {
saveRequest(beginDateTime = "2026-01-01T00:00:00")
val matchedId = saveRequest(beginDateTime = "2026-01-15T00:00:00")
saveRequest(beginDateTime = "2026-02-01T00:00:00", endDateTime = "2026-02-28T00:00:00")
val response = service.listRequests(
ListRequestsQueryDto(
beginFrom = OffsetDateTime.parse("2026-01-10T00:00:00Z"),
beginTo = OffsetDateTime.parse("2026-01-31T23:59:59Z"),
)
)
assertEquals(listOf(matchedId), response.items.map { request -> request.id })
}
@Test
fun `list requests filters by end date range inclusively`() {
saveRequest(endDateTime = "2026-01-09T00:00:00")
val matchedId = saveRequest(endDateTime = "2026-01-20T00:00:00")
saveRequest(endDateTime = "2026-02-01T00:00:00")
val response = service.listRequests(
ListRequestsQueryDto(
endFrom = OffsetDateTime.parse("2026-01-10T00:00:00Z"),
endTo = OffsetDateTime.parse("2026-01-31T23:59:59Z"),
)
)
assertEquals(listOf(matchedId), response.items.map { request -> request.id })
}
@Test
fun `list requests paginates with total items and total pages from database page`() {
saveRequest(createdAt = "2026-01-03T00:00:00")
saveRequest(createdAt = "2026-01-02T00:00:00")
val oldestId = saveRequest(createdAt = "2026-01-01T00:00:00")
val response = service.listRequests(ListRequestsQueryDto(page = 1, size = 2))
assertEquals(listOf(oldestId), response.items.map { request -> request.id })
assertEquals(1, response.page)
assertEquals(2, response.size)
assertEquals(3, response.totalItems)
assertEquals(2, response.totalPages)
}
@Test
fun `list requests sorts by created at descending by default`() {
val oldestId = saveRequest(createdAt = "2026-01-01T00:00:00")
val newestId = saveRequest(createdAt = "2026-01-03T00:00:00")
val middleId = saveRequest(createdAt = "2026-01-02T00:00:00")
val response = service.listRequests(ListRequestsQueryDto())
assertEquals(listOf(newestId, middleId, oldestId), response.items.map { request -> request.id })
}
@Test
fun `list requests supports whitelisted sort field`() {
val latestBeginId = saveRequest(beginDateTime = "2026-01-03T00:00:00")
val earliestBeginId = saveRequest(beginDateTime = "2026-01-01T00:00:00")
val response = service.listRequests(ListRequestsQueryDto(sort = "beginDateTime,asc"))
assertEquals(listOf(earliestBeginId, latestBeginId), response.items.map { request -> request.id })
}
@Test
fun `get request with cells loads request cell projections with earth cell numbers`() {
val requestId = saveRequest()
val earthCell = earthCellRepository.saveAndFlush(
EarthCellEntity(
cellNum = 32400,
latitude = 55.0,
longitude = 37.0,
importance = 5.0,
contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))",
)
)
requestCellRepository.saveAndFlush(
RequestCellEntity(
requestId = requestId,
coveragePercent = 25.0,
importance = 7.0,
contour = "POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))",
cell = earthCell,
)
)
entityManager.clear()
val response = service.getRequestWithCells(requestId)
assertEquals(requestId, response?.request?.id)
assertEquals("Тест1", response?.request?.name)
val cell = response?.cells?.single()
assertEquals(32400L, cell?.cellNum)
assertEquals(25.0, cell?.coveragePercent)
assertEquals(7.0, cell?.importance)
assertEquals("POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))", cell?.contour)
}
private fun saveRequest(
status: RequestStatus = RequestStatus.ACTIVE,
surveyType: RequestSurveyType = RequestSurveyType.OPTICS,
kpp: MutableList<Int> = mutableListOf(1),
highPriorityTransmit: Boolean = false,
beginDateTime: String = "2026-01-01T00:00:00",
endDateTime: String = "2026-01-31T00:00:00",
createdAt: String = "2026-01-01T00:00:00",
): UUID {
val id = UUID.randomUUID()
val geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))"
requestRepository.saveAndFlush(
RequestEntity(
id = id,
name = "Тест1",
geometry = geometry,
remainingGeometry = geometry,
geometryArea = 1.0,
remainingArea = 0.5,
coverageRequiredPercent = 100.0,
importance = 10.0,
beginDateTime = LocalDateTime.parse(beginDateTime),
endDateTime = LocalDateTime.parse(endDateTime),
kpp = kpp,
highPriorityTransmit = highPriorityTransmit,
surveyType = surveyType,
status = status,
createdAt = LocalDateTime.parse(createdAt),
updatedAt = LocalDateTime.parse(createdAt),
deletedAt = if (status == RequestStatus.DELETED) LocalDateTime.parse(createdAt) else null,
)
)
return id
}
}
@@ -0,0 +1,314 @@
package org.nstart.dep265.requestservice.service
import jakarta.persistence.EntityManager
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.ArgumentMatchers.isNull
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 org.mockito.Mockito.verifyNoInteractions
import org.nstart.dep265.requestservice.dto.CreateRequestRequestDto
import org.nstart.dep265.requestservice.dto.ListRequestsQueryDto
import org.nstart.dep265.requestservice.dto.OpticsParamsDto
import org.nstart.dep265.requestservice.dto.OpticsResultTypeDto
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.entity.RequestOpticsParamsEntity
import org.nstart.dep265.requestservice.entity.RequestRsaParamsEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.entity.RequestSurveyType
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestOpticsParamsRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import org.nstart.dep265.requestservice.repository.RequestRsaParamsRepository
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.util.Optional
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class RequestServiceTest {
private val requestRepository = mock(RequestRepository::class.java)
private val requestOpticsParamsRepository = mock(RequestOpticsParamsRepository::class.java)
private val requestRsaParamsRepository = mock(RequestRsaParamsRepository::class.java)
private val requestCellRepository = mock(RequestCellRepository::class.java)
private val requestGridProjectionService = mock(RequestGridProjectionService::class.java)
private val entityManager = mock(EntityManager::class.java)
private val service = RequestService(
requestRepository = requestRepository,
requestOpticsParamsRepository = requestOpticsParamsRepository,
requestRsaParamsRepository = requestRsaParamsRepository,
requestCellRepository = requestCellRepository,
geometryService = GeometryService(),
requestGridProjectionService = requestGridProjectionService,
entityManager = entityManager,
)
init {
doAnswer { invocation -> invocation.getArgument<RequestOpticsParamsEntity>(0) }
.`when`(requestOpticsParamsRepository)
.save(any(RequestOpticsParamsEntity::class.java))
doAnswer { invocation -> invocation.getArgument<RequestRsaParamsEntity>(0) }
.`when`(requestRsaParamsRepository)
.save(any(RequestRsaParamsEntity::class.java))
doReturn(Optional.empty<RequestOpticsParamsEntity>())
.`when`(requestOpticsParamsRepository)
.findById(any())
doReturn(Optional.empty<RequestRsaParamsEntity>())
.`when`(requestRsaParamsRepository)
.findById(any())
doReturn(emptyList<RequestCellEntity>())
.`when`(requestCellRepository)
.findWithCellByRequestId(uuidMatcher())
}
@Test
fun `create request inserts new request with default required coverage and input importance`() {
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))
val response = service.createRequest(createRequestDto())
assertEquals(REQUEST_ID, response.id)
assertEquals(REQUEST_NAME, response.name)
assertEquals(REQUEST_IMPORTANCE, response.importance)
assertEquals(1, persistedRequests.size)
assertEquals(REQUEST_NAME, persistedRequests.single().name)
assertEquals(100.0, persistedRequests.single().coverageRequiredPercent)
assertEquals(REQUEST_IMPORTANCE, persistedRequests.single().importance)
verify(requestRepository, never()).save(any(RequestEntity::class.java))
verify(requestGridProjectionService).rebuildProjection(persistedRequests.single())
}
@Test
fun `get request by id returns name and importance`() {
val request = existingRequest()
doReturn(Optional.of(request))
.`when`(requestRepository)
.findById(REQUEST_ID)
val response = service.getRequestById(REQUEST_ID)
assertEquals(REQUEST_NAME, response?.name)
assertEquals(REQUEST_IMPORTANCE, response?.importance)
}
@Test
fun `get request with cells returns request read model and request cell projection fields`() {
val request = existingRequest()
val earthCell = EarthCellEntity(
cellId = 101,
cellNum = 32400,
latitude = 55.0,
longitude = 37.0,
importance = 5.0,
contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))",
)
doReturn(Optional.of(request))
.`when`(requestRepository)
.findById(REQUEST_ID)
doReturn(
listOf(
RequestCellEntity(
requestId = REQUEST_ID,
coveragePercent = 25.0,
importance = 7.0,
contour = "POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))",
cell = earthCell,
)
)
)
.`when`(requestCellRepository)
.findWithCellByRequestId(REQUEST_ID)
val response = service.getRequestWithCells(REQUEST_ID)
assertEquals(REQUEST_ID, response?.request?.id)
assertEquals(REQUEST_NAME, response?.request?.name)
assertEquals(REQUEST_IMPORTANCE, response?.request?.importance)
val cell = response?.cells?.single()
assertEquals(32400L, cell?.cellNum)
assertEquals(25.0, cell?.coveragePercent)
assertEquals(7.0, cell?.importance)
assertEquals("POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))", cell?.contour)
verify(requestCellRepository).findWithCellByRequestId(REQUEST_ID)
}
@Test
fun `get request with cells returns empty cells for request without projections`() {
doReturn(Optional.of(existingRequest()))
.`when`(requestRepository)
.findById(REQUEST_ID)
doReturn(emptyList<RequestCellEntity>())
.`when`(requestCellRepository)
.findWithCellByRequestId(REQUEST_ID)
val response = service.getRequestWithCells(REQUEST_ID)
assertEquals(REQUEST_ID, response?.request?.id)
assertEquals(emptyList(), response?.cells)
}
@Test
fun `get request with cells returns deleted request with empty cells`() {
val request = existingRequest().also { existingRequest ->
existingRequest.status = RequestStatus.DELETED
existingRequest.deletedAt = LocalDateTime.parse("2026-02-01T00:00:00")
}
doReturn(Optional.of(request))
.`when`(requestRepository)
.findById(REQUEST_ID)
doReturn(emptyList<RequestCellEntity>())
.`when`(requestCellRepository)
.findWithCellByRequestId(REQUEST_ID)
val response = service.getRequestWithCells(REQUEST_ID)
assertEquals(RequestStatus.DELETED.name, response?.request?.status?.name)
assertEquals(emptyList(), response?.cells)
}
@Test
fun `get request with cells returns null when request is absent`() {
doReturn(Optional.empty<RequestEntity>())
.`when`(requestRepository)
.findById(REQUEST_ID)
val response = service.getRequestWithCells(REQUEST_ID)
assertEquals(null, response)
verify(requestCellRepository, never()).findWithCellByRequestId(REQUEST_ID)
}
@Test
fun `list requests returns name and importance in summary`() {
doReturn(PageImpl(listOf(existingRequest())))
.`when`(requestRepository)
.findRequests(
anyBoolean(),
deletedStatusMatcher(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
pageableMatcher(),
)
val response = service.listRequests(ListRequestsQueryDto())
assertEquals(REQUEST_NAME, response.items.single().name)
assertEquals(REQUEST_IMPORTANCE, response.items.single().importance)
}
@Test
fun `create request rejects duplicate id without touching existing request state`() {
doReturn(true)
.`when`(requestRepository)
.existsById(REQUEST_ID)
assertFailsWith<RequestAlreadyExistsException> {
service.createRequest(createRequestDto())
}
verify(entityManager, never()).persist(any(RequestEntity::class.java))
verify(requestRepository, never()).save(any(RequestEntity::class.java))
verifyNoInteractions(requestGridProjectionService)
verify(requestCellRepository, never()).deleteByRequestId(REQUEST_ID)
}
@Test
fun `delete request soft deletes request and removes request cells projection`() {
val request = existingRequest()
doReturn(Optional.of(request))
.`when`(requestRepository)
.findById(REQUEST_ID)
doAnswer { invocation -> invocation.getArgument<RequestEntity>(0) }
.`when`(requestRepository)
.save(any(RequestEntity::class.java))
val response = service.deleteRequest(REQUEST_ID)
assertEquals(REQUEST_ID, response?.id)
assertEquals(RequestStatus.DELETED, request.status)
verify(requestCellRepository).deleteByRequestId(REQUEST_ID)
}
private fun createRequestDto(): CreateRequestRequestDto {
return CreateRequestRequestDto(
id = REQUEST_ID,
name = REQUEST_NAME,
geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))",
importance = REQUEST_IMPORTANCE,
beginDateTime = OffsetDateTime.parse("2026-01-01T00:00:00Z"),
endDateTime = OffsetDateTime.parse("2026-01-31T23:59:59Z"),
optics = OpticsParamsDto(
resultType = OpticsResultTypeDto.PANCHROMATIC,
resolution = 1.0,
),
)
}
private fun existingRequest(): 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 = REQUEST_ID,
name = REQUEST_NAME,
geometry = geometry,
remainingGeometry = geometry,
geometryArea = 1.0,
remainingArea = 0.5,
coverageRequiredPercent = 100.0,
importance = REQUEST_IMPORTANCE,
beginDateTime = LocalDateTime.parse("2026-01-01T00:00:00"),
endDateTime = LocalDateTime.parse("2026-01-31T23:59:59"),
surveyType = RequestSurveyType.OPTICS,
status = RequestStatus.ACTIVE,
matchCount = 1,
completedAt = null,
createdAt = LocalDateTime.parse("2025-12-31T00:00:00"),
updatedAt = LocalDateTime.parse("2025-12-31T00:00:00"),
)
}
private companion object {
val REQUEST_ID: UUID = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6")
const val REQUEST_NAME = "Тест1"
const val REQUEST_IMPORTANCE = 10.0
}
private fun deletedStatusMatcher(): RequestStatus {
any(RequestStatus::class.java)
return RequestStatus.DELETED
}
private fun pageableMatcher(): Pageable {
any(Pageable::class.java)
return PageRequest.of(0, 50)
}
private fun uuidMatcher(): UUID {
any(UUID::class.java)
return REQUEST_ID
}
}
@@ -0,0 +1,412 @@
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.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.doThrow
import org.mockito.Mockito.mock
import org.mockito.Mockito.mockingDetails
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoInteractions
import org.nstart.dep265.requestservice.dto.RouteAngleRangeDto
import org.nstart.dep265.requestservice.dto.RouteDto
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestRouteMatchEntity
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.nstart.dep265.requestservice.repository.RequestRouteMatchRepository
import org.springframework.dao.DataIntegrityViolationException
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.Optional
import java.util.UUID
class RouteMatchingServiceTest {
@Test
fun `route intersecting original and remaining stores contributing match and updates coverage`() {
val requestRepository = mock(RequestRepository::class.java)
val requestRouteMatchRepository = mock(RequestRouteMatchRepository::class.java)
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val geometryService = GeometryService()
val routeMatchingService = routeMatchingService(
requestRepository = requestRepository,
requestRouteMatchRepository = requestRouteMatchRepository,
outboxEventRepository = outboxEventRepository,
geometryService = geometryService,
)
val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val intervalEnd = intervalBegin.plusHours(1)
val request = request(
beginDateTime = intervalBegin,
endDateTime = intervalEnd,
geometry = "POLYGON((0 0, 2 0, 2 2, 0 2, 0 0))",
remainingGeometry = "POLYGON((0 0, 2 0, 2 2, 0 2, 0 0))",
geometryArea = 4.0,
remainingArea = 4.0,
status = RequestStatus.ACTIVE,
)
val route = route(
intervalBegin = intervalBegin.plusMinutes(1),
intervalEnd = intervalBegin.plusMinutes(2),
geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))",
)
stubCandidate(requestRepository, requestRouteMatchRepository, request, route)
routeMatchingService.process(route)
val routeMatch = captureSavedRouteMatch(requestRouteMatchRepository)
assertEquals(request.id, routeMatch.requestId)
assertEquals(route.routeId, routeMatch.routeId)
assertTrue(routeMatch.contributesToCoverage)
assertTrue(routeMatch.originalIntersectionArea > 0.0)
assertTrue(routeMatch.appliedIntersectionArea > 0.0)
assertTrue(routeMatch.coverageDeltaPercent > 0.0)
assertEquals(3.0, request.remainingArea)
assertEquals(1, request.matchCount)
assertEquals(RequestStatus.ACTIVE, request.status)
verify(requestRepository).findByIdForUpdate(request.id)
verify(requestRepository).save(request)
verifyNoInteractions(outboxEventRepository)
}
@Test
fun `route intersecting original but not remaining stores non contributing match without coverage update`() {
val requestRepository = mock(RequestRepository::class.java)
val requestRouteMatchRepository = mock(RequestRouteMatchRepository::class.java)
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val geometryService = GeometryService()
val routeMatchingService = routeMatchingService(
requestRepository = requestRepository,
requestRouteMatchRepository = requestRouteMatchRepository,
outboxEventRepository = outboxEventRepository,
geometryService = geometryService,
)
val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val intervalEnd = intervalBegin.plusHours(1)
val originalRemainingGeometry = "POLYGON((1 1, 2 1, 2 2, 1 2, 1 1))"
val request = request(
beginDateTime = intervalBegin,
endDateTime = intervalEnd,
geometry = "POLYGON((0 0, 2 0, 2 2, 0 2, 0 0))",
remainingGeometry = originalRemainingGeometry,
geometryArea = 4.0,
remainingArea = 1.0,
status = RequestStatus.ACTIVE,
)
val route = route(
intervalBegin = intervalBegin.plusMinutes(1),
intervalEnd = intervalBegin.plusMinutes(2),
geometry = "POLYGON((0 0, 0.5 0, 0.5 0.5, 0 0.5, 0 0))",
)
stubCandidate(requestRepository, requestRouteMatchRepository, request, route)
routeMatchingService.process(route)
val routeMatch = captureSavedRouteMatch(requestRouteMatchRepository)
assertEquals(request.id, routeMatch.requestId)
assertEquals(route.routeId, routeMatch.routeId)
assertEquals(false, routeMatch.contributesToCoverage)
assertTrue(routeMatch.originalIntersectionArea > 0.0)
assertNull(routeMatch.appliedIntersectionGeometry)
assertEquals(0.0, routeMatch.appliedIntersectionArea)
assertEquals(0.0, routeMatch.coverageDeltaPercent)
assertEquals(originalRemainingGeometry, request.remainingGeometry)
assertEquals(1.0, request.remainingArea)
assertEquals(0, request.matchCount)
verify(requestRepository, never()).save(request)
verifyNoInteractions(outboxEventRepository)
}
@Test
fun `route outside original geometry does not create match or update request`() {
val requestRepository = mock(RequestRepository::class.java)
val requestRouteMatchRepository = mock(RequestRouteMatchRepository::class.java)
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val geometryService = GeometryService()
val routeMatchingService = routeMatchingService(
requestRepository = requestRepository,
requestRouteMatchRepository = requestRouteMatchRepository,
outboxEventRepository = outboxEventRepository,
geometryService = geometryService,
)
val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val request = request(
beginDateTime = intervalBegin,
endDateTime = intervalBegin.plusHours(1),
status = RequestStatus.ACTIVE,
)
val route = route(
intervalBegin = intervalBegin.plusMinutes(1),
intervalEnd = intervalBegin.plusMinutes(2),
geometry = "POLYGON((5 5, 6 5, 6 6, 5 6, 5 5))",
)
stubCandidate(requestRepository, requestRouteMatchRepository, request, route)
routeMatchingService.process(route)
verify(requestRouteMatchRepository, never()).saveAndFlush(org.mockito.ArgumentMatchers.any())
verify(requestRepository, never()).save(request)
verifyNoInteractions(outboxEventRepository)
assertEquals(1.0, request.remainingArea)
assertEquals(0, request.matchCount)
}
@Test
fun `duplicate route match after lock skips geometry and coverage update`() {
val requestRepository = mock(RequestRepository::class.java)
val requestRouteMatchRepository = mock(RequestRouteMatchRepository::class.java)
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val geometryService = GeometryService()
val routeMatchingService = routeMatchingService(
requestRepository = requestRepository,
requestRouteMatchRepository = requestRouteMatchRepository,
outboxEventRepository = outboxEventRepository,
geometryService = geometryService,
)
val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val request = request(
beginDateTime = intervalBegin,
endDateTime = intervalBegin.plusHours(1),
status = RequestStatus.ACTIVE,
)
val route = route(
intervalBegin = intervalBegin.plusMinutes(1),
intervalEnd = intervalBegin.plusMinutes(2),
geometry = request.geometry,
)
doReturn(listOf(request.id))
.`when`(requestRepository)
.findRequestIdsForRoute(
listOf(RequestStatus.ACCEPTED, RequestStatus.ACTIVE),
route.intervalBegin,
route.intervalEnd,
)
doReturn(false, true)
.`when`(requestRouteMatchRepository)
.existsByRequestIdAndRouteId(request.id, route.routeId)
doReturn(Optional.of(request))
.`when`(requestRepository)
.findByIdForUpdate(request.id)
routeMatchingService.process(route)
verify(requestRouteMatchRepository, never()).saveAndFlush(org.mockito.ArgumentMatchers.any())
verify(requestRepository, never()).save(request)
verifyNoInteractions(outboxEventRepository)
assertEquals(1.0, request.remainingArea)
assertEquals(0, request.matchCount)
}
@Test
fun `duplicate unique violation on match insert is handled as duplicate delivery`() {
val requestRepository = mock(RequestRepository::class.java)
val requestRouteMatchRepository = mock(RequestRouteMatchRepository::class.java)
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val geometryService = GeometryService()
val routeMatchingService = routeMatchingService(
requestRepository = requestRepository,
requestRouteMatchRepository = requestRouteMatchRepository,
outboxEventRepository = outboxEventRepository,
geometryService = geometryService,
)
val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val request = request(
beginDateTime = intervalBegin,
endDateTime = intervalBegin.plusHours(1),
status = RequestStatus.ACTIVE,
)
val route = route(
intervalBegin = intervalBegin.plusMinutes(1),
intervalEnd = intervalBegin.plusMinutes(2),
geometry = request.geometry,
)
stubCandidate(requestRepository, requestRouteMatchRepository, request, route)
doThrow(DataIntegrityViolationException("duplicate key violates unique constraint uk_request_route_match"))
.`when`(requestRouteMatchRepository)
.saveAndFlush(org.mockito.ArgumentMatchers.any())
routeMatchingService.process(route)
verify(requestRepository, never()).save(request)
verifyNoInteractions(outboxEventRepository)
assertEquals(1.0, request.remainingArea)
assertEquals(0, request.matchCount)
}
@Test
fun `route completing request creates exactly one request completed outbox event`() {
val requestRepository = mock(RequestRepository::class.java)
val requestRouteMatchRepository = mock(RequestRouteMatchRepository::class.java)
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val geometryService = GeometryService()
val routeMatchingService = routeMatchingService(
requestRepository = requestRepository,
requestRouteMatchRepository = requestRouteMatchRepository,
outboxEventRepository = outboxEventRepository,
geometryService = geometryService,
)
val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val request = request(
beginDateTime = intervalBegin,
endDateTime = intervalBegin.plusHours(1),
status = RequestStatus.ACTIVE,
)
val route = route(
intervalBegin = intervalBegin.plusMinutes(1),
intervalEnd = intervalBegin.plusMinutes(2),
geometry = request.geometry,
)
stubCandidate(requestRepository, requestRouteMatchRepository, request, route)
routeMatchingService.process(route)
val insertInvocations = mockingDetails(outboxEventRepository).invocations.filter {
invocation -> invocation.method.name == "insertIfAbsent"
}
assertEquals(1, insertInvocations.size)
assertEquals("REQUEST_COMPLETED", insertInvocations.single().arguments[1])
assertEquals(request.id, insertInvocations.single().arguments[3])
assertEquals(RequestStatus.COMPLETED, request.status)
}
private fun routeMatchingService(
requestRepository: RequestRepository,
requestRouteMatchRepository: RequestRouteMatchRepository,
outboxEventRepository: OutboxEventRepository,
geometryService: GeometryService,
): RouteMatchingService {
return RouteMatchingService(
requestRepository = requestRepository,
requestRouteMatchRepository = requestRouteMatchRepository,
requestValidationService = RequestValidationService(geometryService),
geometryService = geometryService,
requestLifecycleService = RequestLifecycleService(geometryService),
requestCompletedOutboxService = requestCompletedOutboxService(outboxEventRepository, geometryService),
routeMatchingTransactionOperations = ImmediateTransactionOperations(),
)
}
private fun requestCompletedOutboxService(
outboxEventRepository: OutboxEventRepository,
geometryService: GeometryService,
): RequestCompletedOutboxService {
return RequestCompletedOutboxService(
outboxEventRepository = outboxEventRepository,
objectMapper = jacksonObjectMapper()
.registerModule(JavaTimeModule())
.findAndRegisterModules(),
geometryService = geometryService,
)
}
private fun stubCandidate(
requestRepository: RequestRepository,
requestRouteMatchRepository: RequestRouteMatchRepository,
request: RequestEntity,
route: RouteDto,
) {
doReturn(listOf(request.id))
.`when`(requestRepository)
.findRequestIdsForRoute(
listOf(RequestStatus.ACCEPTED, RequestStatus.ACTIVE),
route.intervalBegin,
route.intervalEnd,
)
doReturn(false)
.`when`(requestRouteMatchRepository)
.existsByRequestIdAndRouteId(request.id, route.routeId)
doReturn(Optional.of(request))
.`when`(requestRepository)
.findByIdForUpdate(request.id)
doReturn(request)
.`when`(requestRepository)
.save(request)
}
private fun captureSavedRouteMatch(
requestRouteMatchRepository: RequestRouteMatchRepository,
): RequestRouteMatchEntity {
val routeMatchCaptor = ArgumentCaptor.forClass(RequestRouteMatchEntity::class.java)
verify(requestRouteMatchRepository).saveAndFlush(routeMatchCaptor.capture())
return routeMatchCaptor.value
}
private fun request(
beginDateTime: LocalDateTime,
endDateTime: LocalDateTime,
geometry: String = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))",
remainingGeometry: String = geometry,
geometryArea: Double = 1.0,
remainingArea: Double = geometryArea,
status: RequestStatus,
completedAt: LocalDateTime? = null,
): RequestEntity {
val createdAt = beginDateTime.minusHours(1)
return RequestEntity(
id = UUID.randomUUID(),
name = "Тест1",
geometry = geometry,
remainingGeometry = remainingGeometry,
geometryArea = geometryArea,
remainingArea = remainingArea,
importance = 10.0,
beginDateTime = beginDateTime,
endDateTime = endDateTime,
highPriorityTransmit = false,
surveyType = RequestSurveyType.OPTICS,
status = status,
matchCount = 0,
completedAt = completedAt,
createdAt = createdAt,
updatedAt = createdAt,
)
}
private fun route(
intervalBegin: LocalDateTime,
intervalEnd: LocalDateTime,
geometry: String,
): RouteDto {
return RouteDto(
routeId = UUID.randomUUID(),
intervalBegin = intervalBegin,
intervalEnd = intervalEnd,
rollAngle = RouteAngleRangeDto(
min = 10.0,
max = 14.0,
),
geometry = geometry,
)
}
private class ImmediateTransactionOperations : TransactionOperations {
override fun <T> execute(action: TransactionCallback<T>): T {
val status: TransactionStatus = SimpleTransactionStatus()
return action.doInTransaction(status)
}
}
}
@@ -0,0 +1,6 @@
spring:
config:
import: ""
cloud:
config:
enabled: false