Init
This commit is contained in:
+97
@@ -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)
|
||||
}
|
||||
}
|
||||
+208
@@ -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)
|
||||
}
|
||||
}
|
||||
+127
@@ -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)
|
||||
}
|
||||
}
|
||||
+508
@@ -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")
|
||||
}
|
||||
}
|
||||
+126
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+46
@@ -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)
|
||||
}
|
||||
}
|
||||
+109
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+293
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+258
@@ -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)
|
||||
}
|
||||
}
|
||||
+177
@@ -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
|
||||
}
|
||||
}
|
||||
+162
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+98
@@ -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))
|
||||
}
|
||||
}
|
||||
+282
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -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"])
|
||||
}
|
||||
}
|
||||
+142
@@ -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)
|
||||
}
|
||||
}
|
||||
+255
@@ -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
|
||||
}
|
||||
}
|
||||
+314
@@ -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
|
||||
}
|
||||
}
|
||||
+412
@@ -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
|
||||
Reference in New Issue
Block a user