pcp-tgu
This commit is contained in:
File diff suppressed because one or more lines are too long
+13
-4
@@ -1,6 +1,7 @@
|
||||
package space.nstart.pcp_tgu_service.config
|
||||
|
||||
/** Настраивает Jackson-сериализацию для payload'ов клиента Camunda. */
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.SerializationFeature
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||
@@ -13,15 +14,23 @@ import org.springframework.context.annotation.Configuration
|
||||
@Configuration
|
||||
class CamundaJacksonConfig {
|
||||
|
||||
/** Создаёт JSON mapper для Camunda с поддержкой Kotlin-классов и Java time типов. */
|
||||
@Bean("camundaJsonMapper")
|
||||
fun camundaJsonMapper(): CamundaJsonMapper {
|
||||
val objectMapper = JsonMapper.builder()
|
||||
/**
|
||||
* The service code still depends on Jackson 2 APIs, while the shared Boot 4 stack
|
||||
* auto-configures Jackson 3 for WebFlux. Keep a dedicated Jackson 2 mapper for
|
||||
* internal parsing and Camunda payloads until the module is migrated.
|
||||
*/
|
||||
@Bean
|
||||
fun objectMapper(): ObjectMapper {
|
||||
return JsonMapper.builder()
|
||||
.addModule(KotlinModule.Builder().build())
|
||||
.addModule(JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** Создаёт JSON mapper для Camunda с поддержкой Kotlin-классов и Java time типов. */
|
||||
@Bean("camundaJsonMapper")
|
||||
fun camundaJsonMapper(objectMapper: ObjectMapper): CamundaJsonMapper {
|
||||
return CamundaObjectMapper(objectMapper)
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import reactor.core.publisher.Mono
|
||||
import reactor.core.scheduler.Schedulers
|
||||
import space.nstart.pcp_tgu_service.domain.PlanDecision
|
||||
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage
|
||||
import space.nstart.pcp_tgu_service.dto.PlanResponse
|
||||
import space.nstart.pcp_tgu_service.dto.ProcessStartResponse
|
||||
@@ -19,8 +20,12 @@ import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase
|
||||
import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker
|
||||
import space.nstart.pcp_tgu_service.service.PlanQueryService
|
||||
import space.nstart.pcp_tgu_service.service.PlanRecalculationJobService
|
||||
import space.nstart.pcp_tgu_service.service.PlatformService
|
||||
import space.nstart.pcp_tgu_service.service.RebuildPlanScheduleUseCase
|
||||
import space.nstart.pcp_tgu_service.service.TestPlanDecisionService
|
||||
import space.nstart.pcp_tgu_service.service.VisibilityRefreshJobService
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@@ -36,7 +41,9 @@ class TestTguController(
|
||||
private val rebuildPlanScheduleUseCase: RebuildPlanScheduleUseCase,
|
||||
private val issueDuePlanWorker: IssueDuePlanWorker,
|
||||
private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase,
|
||||
private val testPlanDecisionService: TestPlanDecisionService,
|
||||
private val externalPointsClient: ExternalPointsClient,
|
||||
private val platformService: PlatformService,
|
||||
private val planQueryService: PlanQueryService
|
||||
) {
|
||||
/** Ставит refresh job для КА. */
|
||||
@@ -71,6 +78,30 @@ class TestTguController(
|
||||
handlePlanDecisionUseCase.handle(message)
|
||||
}.subscribeOn(Schedulers.boundedElastic())
|
||||
|
||||
/** Генерирует ACCEPTED/REJECTED decision event для active attempt указанного persisted plan. */
|
||||
@PostMapping("/plans/{planId}/decision")
|
||||
fun generatePlanDecision(
|
||||
@PathVariable planId: UUID,
|
||||
@RequestParam decision: PlanDecision,
|
||||
@RequestParam(required = false) reason: String?
|
||||
): Mono<PlanDecisionMessage> =
|
||||
Mono.fromCallable {
|
||||
testPlanDecisionService.generateDecision(planId, decision, reason)
|
||||
}.subscribeOn(Schedulers.boundedElastic())
|
||||
|
||||
/** Загружает cache аппаратов из файла с root-array payload НСИ вместо запроса в НСИ. */
|
||||
@PostMapping("/platforms/load-from-file")
|
||||
fun loadPlatformsFromFile(@RequestParam("path") filePath: String): Mono<TestPlatformsLoadResponse> =
|
||||
Mono.fromCallable {
|
||||
val path = Path.of(filePath).toAbsolutePath().normalize()
|
||||
val platforms = platformService.loadPlatformsFromNsiPayload(Files.readString(path))
|
||||
TestPlatformsLoadResponse(
|
||||
filePath = path.toString(),
|
||||
loadedCount = platforms.size,
|
||||
allowedCount = platformService.loadAllowedPlatforms().size
|
||||
)
|
||||
}.subscribeOn(Schedulers.boundedElastic())
|
||||
|
||||
/** Загружает raw payload окон видимости из баллистики для диагностики. */
|
||||
@GetMapping("/external-points/{noradId}")
|
||||
fun getExternalPoints(
|
||||
@@ -93,3 +124,9 @@ class TestTguController(
|
||||
PlanResponse.from(planQueryService.getByPlanId(planId))
|
||||
}.subscribeOn(Schedulers.boundedElastic())
|
||||
}
|
||||
|
||||
data class TestPlatformsLoadResponse(
|
||||
val filePath: String,
|
||||
val loadedCount: Int,
|
||||
val allowedCount: Int
|
||||
)
|
||||
|
||||
+14
-1
@@ -1,6 +1,7 @@
|
||||
package space.nstart.pcp_tgu_service.service
|
||||
|
||||
/** Загружает метаданные платформ КА и применяет простую in-memory фильтрацию. */
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import space.nstart.pcp_tgu_service.config.ClassifierProperties
|
||||
import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem
|
||||
import space.nstart.pcp_tgu_service.dto.NsiPlatformDto
|
||||
@@ -14,6 +15,7 @@ import java.time.Instant
|
||||
class PlatformService(
|
||||
private val platformsClassifierClient: PlatformsClassifierClient,
|
||||
private val classifierProperties: ClassifierProperties,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val clock: Clock = Clock.systemUTC()
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
@@ -40,6 +42,13 @@ class PlatformService(
|
||||
now.isBefore(platform.validTo)
|
||||
}
|
||||
|
||||
/** Replaces the in-memory platform cache from a raw NSI response payload for test-only manual runs. */
|
||||
fun loadPlatformsFromNsiPayload(payloadJson: String): List<PlatformCatalogItem> =
|
||||
synchronized(cacheLock) {
|
||||
val platforms = objectMapper.readValue(payloadJson, Array<NsiPlatformDto>::class.java).toList()
|
||||
cachePlatforms(platforms, Instant.now(clock))
|
||||
}
|
||||
|
||||
private fun loadCachedPlatforms(): List<PlatformCatalogItem> {
|
||||
val now = Instant.now(clock)
|
||||
cachedPlatforms?.takeIf { cache -> cache.isActual(now) }?.let { cache -> return cache.platforms }
|
||||
@@ -54,7 +63,11 @@ class PlatformService(
|
||||
}
|
||||
|
||||
private fun fetchAndCachePlatforms(now: Instant): List<PlatformCatalogItem> {
|
||||
val platforms = platformsClassifierClient.fetchPlatforms()
|
||||
return cachePlatforms(platformsClassifierClient.fetchPlatforms(), now)
|
||||
}
|
||||
|
||||
private fun cachePlatforms(platformDtos: List<NsiPlatformDto>, now: Instant): List<PlatformCatalogItem> {
|
||||
val platforms = platformDtos
|
||||
.mapNotNull { platform -> platform.toCatalogItem() }
|
||||
|
||||
cachedPlatforms = PlatformsCache(
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package space.nstart.pcp_tgu_service.service
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import space.nstart.pcp_tgu_service.domain.PlanDecision
|
||||
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage
|
||||
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus
|
||||
import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository
|
||||
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Test-only helper: генерирует decision event для текущей active attempt persisted plan.
|
||||
*/
|
||||
@ConditionalOnProperty(prefix = "tgu.test-controller", name = ["enabled"], havingValue = "true")
|
||||
@Service
|
||||
class TestPlanDecisionService(
|
||||
private val plannedPlanRepository: PlannedPlanRepository,
|
||||
private val planIssueAttemptRepository: PlanIssueAttemptRepository,
|
||||
private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository,
|
||||
private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase
|
||||
) {
|
||||
/**
|
||||
* Создаёт synthetic decision event и проводит его через тот же use case, что Kafka consumer.
|
||||
*/
|
||||
fun generateDecision(planId: UUID, decision: PlanDecision, reason: String?): PlanDecisionMessage {
|
||||
val plan = plannedPlanRepository.findById(planId).orElse(null)
|
||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found")
|
||||
val state = spacecraftPlanningStateJpaRepository.findById(plan.spacecraftId).orElse(null)
|
||||
?: throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"Planning state for spacecraftId=${plan.spacecraftId} was not found"
|
||||
)
|
||||
if (state.activePlanId != planId || state.activeAttemptId == null) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"Plan $planId is not the active plan waiting for decision"
|
||||
)
|
||||
}
|
||||
|
||||
val attempt = planIssueAttemptRepository.findById(state.activeAttemptId!!).orElse(null)
|
||||
?: throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"Active attempt ${state.activeAttemptId} for plan $planId was not found"
|
||||
)
|
||||
if (attempt.planId != planId) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"Active attempt ${attempt.attemptId} does not belong to plan $planId"
|
||||
)
|
||||
}
|
||||
if (attempt.status !in ACTIVE_ATTEMPT_STATUSES) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"Attempt ${attempt.attemptId} is already terminal with status=${attempt.status}"
|
||||
)
|
||||
}
|
||||
|
||||
val message = PlanDecisionMessage(
|
||||
eventId = UUID.randomUUID(),
|
||||
spacecraftId = plan.spacecraftId,
|
||||
planId = plan.planId,
|
||||
attemptId = attempt.attemptId,
|
||||
decision = decision,
|
||||
decisionTime = LocalDateTime.now(),
|
||||
reason = reason?.takeIf { it.isNotBlank() } ?: DEFAULT_REASON
|
||||
)
|
||||
handlePlanDecisionUseCase.handle(message)
|
||||
return message
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val ACTIVE_ATTEMPT_STATUSES = setOf(PlanIssueAttemptStatus.STARTING, PlanIssueAttemptStatus.STARTED)
|
||||
const val DEFAULT_REASON = "TEST_CONTROLLER"
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ spring:
|
||||
import: "configserver:"
|
||||
cloud:
|
||||
config:
|
||||
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
|
||||
uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888}
|
||||
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
|
||||
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
|
||||
label: ${SPRING_CLOUD_CONFIG_LABEL:master}
|
||||
|
||||
+3
-1
@@ -1,5 +1,6 @@
|
||||
package space.nstart.pcp_tgu_service.integration.api
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -57,7 +58,8 @@ class ExternalPointsClientTest {
|
||||
platformsUrl = "http://localhost",
|
||||
platformsCacheTtl = java.time.Duration.ofMinutes(15),
|
||||
allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY")
|
||||
)
|
||||
),
|
||||
jacksonObjectMapper().findAndRegisterModules()
|
||||
) {
|
||||
override fun loadPlatforms(status: String?): List<PlatformCatalogItem> =
|
||||
error("ExternalPointsClient must use loadAllowedPlatforms")
|
||||
|
||||
+37
@@ -1,5 +1,6 @@
|
||||
package space.nstart.pcp_tgu_service.service
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
@@ -121,6 +122,41 @@ class PlatformServiceTest {
|
||||
verify(client, times(1)).fetchPlatforms()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadPlatformsFromNsiPayload replaces cached platforms from file payload`() {
|
||||
val client = mock(PlatformsClassifierClient::class.java)
|
||||
`when`(client.fetchPlatforms()).thenReturn(
|
||||
listOf(platformDto(id = "from-nsi-id", status = "OPERATIONAL", noradId = 1L))
|
||||
)
|
||||
val service = service(client)
|
||||
|
||||
val fromNsi = service.loadPlatforms()
|
||||
val fromFile = service.loadPlatformsFromNsiPayload(
|
||||
"""
|
||||
[
|
||||
{
|
||||
"id": "from-file-id",
|
||||
"businessKey": "business-from-file-id",
|
||||
"data": {
|
||||
"name": "Satellite from-file-id",
|
||||
"status": "OPERATIONAL",
|
||||
"norad_id": 25544,
|
||||
"mission": "MISSION"
|
||||
},
|
||||
"validFrom": "2026-01-01T00:00:00Z",
|
||||
"validTo": "2026-12-31T00:00:00Z"
|
||||
}
|
||||
]
|
||||
""".trimIndent()
|
||||
)
|
||||
val cachedAfterFileLoad = service.loadPlatforms()
|
||||
|
||||
assertEquals(listOf("from-nsi-id"), fromNsi.map { it.nsiId })
|
||||
assertEquals(listOf("from-file-id"), fromFile.map { it.nsiId })
|
||||
assertEquals(listOf("from-file-id"), cachedAfterFileLoad.map { it.nsiId })
|
||||
verify(client, times(1)).fetchPlatforms()
|
||||
}
|
||||
|
||||
private fun service(
|
||||
client: PlatformsClassifierClient,
|
||||
clock: Clock = Clock.fixed(Instant.parse("2026-05-26T12:00:00Z"), ZoneOffset.UTC)
|
||||
@@ -132,6 +168,7 @@ class PlatformServiceTest {
|
||||
platformsCacheTtl = Duration.ofMinutes(15),
|
||||
allowedPlatformStatuses = setOf("OPERATIONAL")
|
||||
),
|
||||
objectMapper = jacksonObjectMapper().findAndRegisterModules(),
|
||||
clock = clock
|
||||
)
|
||||
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package space.nstart.pcp_tgu_service.service
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.verifyNoInteractions
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import space.nstart.pcp_tgu_service.domain.PlanDecision
|
||||
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage
|
||||
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity
|
||||
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
|
||||
import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity
|
||||
import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository
|
||||
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
class TestPlanDecisionServiceTest {
|
||||
|
||||
@Test
|
||||
fun `generate decision builds message for active attempt and delegates to use case`() {
|
||||
val plan = plannedPlan()
|
||||
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
|
||||
val state = SpacecraftPlanningStateEntity(
|
||||
spacecraftId = plan.spacecraftId,
|
||||
activePlanId = plan.planId,
|
||||
activeAttemptId = attempt.attemptId
|
||||
)
|
||||
val handlePlanDecisionUseCase = mock(HandlePlanDecisionUseCase::class.java)
|
||||
val service = service(plan, attempt, state, handlePlanDecisionUseCase)
|
||||
|
||||
val message = service.generateDecision(plan.planId, PlanDecision.ACCEPTED, "manual accept")
|
||||
|
||||
assertNotNull(message.eventId)
|
||||
assertEquals(plan.spacecraftId, message.spacecraftId)
|
||||
assertEquals(plan.planId, message.planId)
|
||||
assertEquals(attempt.attemptId, message.attemptId)
|
||||
assertEquals(PlanDecision.ACCEPTED, message.decision)
|
||||
assertEquals("manual accept", message.reason)
|
||||
verify(handlePlanDecisionUseCase).handle(message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generate decision rejects plan without active attempt`() {
|
||||
val plan = plannedPlan()
|
||||
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = plan.spacecraftId)
|
||||
val handlePlanDecisionUseCase = mock(HandlePlanDecisionUseCase::class.java)
|
||||
val service = service(plan, attempt, state, handlePlanDecisionUseCase)
|
||||
|
||||
val exception = assertThrows(ResponseStatusException::class.java) {
|
||||
service.generateDecision(plan.planId, PlanDecision.REJECTED, null)
|
||||
}
|
||||
|
||||
assertEquals(HttpStatus.CONFLICT, exception.statusCode)
|
||||
verifyNoInteractions(handlePlanDecisionUseCase)
|
||||
}
|
||||
|
||||
private fun service(
|
||||
plan: PlannedPlanEntity,
|
||||
attempt: PlanIssueAttemptEntity,
|
||||
state: SpacecraftPlanningStateEntity,
|
||||
handlePlanDecisionUseCase: HandlePlanDecisionUseCase
|
||||
): TestPlanDecisionService {
|
||||
val plannedPlanRepository = mock(PlannedPlanRepository::class.java)
|
||||
val planIssueAttemptRepository = mock(PlanIssueAttemptRepository::class.java)
|
||||
val stateRepository = mock(SpacecraftPlanningStateJpaRepository::class.java)
|
||||
|
||||
`when`(plannedPlanRepository.findById(plan.planId)).thenReturn(Optional.of(plan))
|
||||
`when`(planIssueAttemptRepository.findById(attempt.attemptId)).thenReturn(Optional.of(attempt))
|
||||
`when`(stateRepository.findById(plan.spacecraftId)).thenReturn(Optional.of(state))
|
||||
|
||||
return TestPlanDecisionService(
|
||||
plannedPlanRepository = plannedPlanRepository,
|
||||
planIssueAttemptRepository = planIssueAttemptRepository,
|
||||
spacecraftPlanningStateJpaRepository = stateRepository,
|
||||
handlePlanDecisionUseCase = handlePlanDecisionUseCase
|
||||
)
|
||||
}
|
||||
|
||||
private fun plannedPlan(): PlannedPlanEntity =
|
||||
PlannedPlanEntity(
|
||||
planId = UUID.randomUUID(),
|
||||
spacecraftId = "25544",
|
||||
startTime = LocalDateTime.now().plusHours(1),
|
||||
endTime = LocalDateTime.now().plusHours(2),
|
||||
kppId = "KPP-1",
|
||||
status = PlannedPlanStatus.WAITING_DECISION
|
||||
)
|
||||
|
||||
private fun attempt(planId: UUID, status: PlanIssueAttemptStatus): PlanIssueAttemptEntity =
|
||||
PlanIssueAttemptEntity(
|
||||
attemptId = UUID.randomUUID(),
|
||||
planId = planId,
|
||||
attemptNo = 1,
|
||||
status = status
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user