pcp: Ф2 — доработки по ревью (валидация ошибок клона, идемпотентность DRAFT, тесты)
- MissionPlaningClient: маппинг ошибок mission-planing (4xx как есть, 5xx → 502) + таймаут 30с - CreateDraftUseCase: гард идемпотентности — второй открытый DRAFT на тот же план → 409 - cloneMission: явная валидация mode (неизвестный → 400 вместо тихого полного клона) - константы DraftMode/CloneMode вместо строковых литералов - kdoc: семантика fork-forward (границы-слот vs времена включений; съёмка без сброса) - тесты: CreateDraftUseCaseTest (13 кейсов), MissionCloneTest (+съёмка без сброса)
This commit is contained in:
+7
-1
@@ -3,8 +3,14 @@ package space.nstart.pcp_tgu_service.dto
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
/** Допустимые значения [CreateDraftRequest.mode]; та же строка уходит на mission-planing. */
|
||||
object DraftMode {
|
||||
const val REPLACE = "replace"
|
||||
const val FORK_FORWARD = "fork-forward"
|
||||
}
|
||||
|
||||
data class CreateDraftRequest(
|
||||
/** "replace" — черновик в том же слоте; "fork-forward" — черновик на следующей ЗРВ. */
|
||||
/** [DraftMode.REPLACE] — черновик в том же слоте; [DraftMode.FORK_FORWARD] — черновик на следующей ЗРВ. */
|
||||
val mode: String,
|
||||
/** Начало окна целевой ЗРВ (UTC naive). Обязательно для fork-forward. */
|
||||
val windowStart: LocalDateTime? = null,
|
||||
|
||||
+21
-4
@@ -1,8 +1,11 @@
|
||||
package space.nstart.pcp_tgu_service.integration.api
|
||||
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import space.nstart.pcp_tgu_service.config.MissionPlaningProperties
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@@ -21,21 +24,35 @@ class MissionPlaningClient(properties: MissionPlaningProperties) {
|
||||
.baseUrl(properties.baseUrl)
|
||||
.build()
|
||||
|
||||
private val timeout: Duration = Duration.ofSeconds(30)
|
||||
|
||||
fun cloneMission(missionId: UUID, req: MissionCloneRequest): MissionCloneResponse =
|
||||
webClient.post()
|
||||
.uri("/api/missions/{id}/clone", missionId)
|
||||
.bodyValue(req)
|
||||
.retrieve()
|
||||
.onStatus({ it.isError }) { resp ->
|
||||
resp.bodyToMono(String::class.java).defaultIfEmpty("").map { body ->
|
||||
// 4xx апстрима пробрасываем как есть (404 — нет базовой миссии и т.п.),
|
||||
// 5xx сворачиваем в 502: проблема не в запросе оператора, а в mission-planing.
|
||||
val status = if (resp.statusCode().is5xxServerError) HttpStatus.BAD_GATEWAY
|
||||
else HttpStatus.valueOf(resp.statusCode().value())
|
||||
ResponseStatusException(status, "Клон миссии $missionId не удался (${resp.statusCode()}): $body")
|
||||
}
|
||||
}
|
||||
.bodyToMono(MissionCloneResponse::class.java)
|
||||
.block()
|
||||
?: throw IllegalStateException("Empty response from mission-planing clone for missionId=$missionId")
|
||||
.block(timeout)
|
||||
?: throw ResponseStatusException(
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
"Пустой ответ mission-planing на клон миссии $missionId"
|
||||
)
|
||||
|
||||
/** Компенсация: удаляет клонированную миссию при сбое сохранения DRAFT-плана. */
|
||||
/** Компенсация: удаляет клонированную миссию при сбое сохранения DRAFT-плана. Best-effort. */
|
||||
fun deleteMission(missionId: UUID) {
|
||||
webClient.delete()
|
||||
.uri("/api/missions/{id}", missionId)
|
||||
.retrieve()
|
||||
.toBodilessEntity()
|
||||
.block()
|
||||
.block(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -4,6 +4,7 @@ import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@@ -12,6 +13,9 @@ interface PlannedPlanRepository : JpaRepository<PlannedPlanEntity, UUID> {
|
||||
/** Возвращает планы КА в порядке старта (для read-проекции и истории). */
|
||||
fun findAllBySpacecraftIdOrderByStartTime(spacecraftId: String): List<PlannedPlanEntity>
|
||||
|
||||
/** Планы, порождённые от данного базового плана с указанным статусом (для гарда дублей DRAFT). */
|
||||
fun findAllByBasePlanIdAndStatus(basePlanId: UUID, status: PlannedPlanStatus): List<PlannedPlanEntity>
|
||||
|
||||
/** Возвращает все планы в порядке старта (для read-проекции и истории). */
|
||||
fun findAllByOrderByStartTime(): List<PlannedPlanEntity>
|
||||
|
||||
|
||||
+17
-4
@@ -7,6 +7,7 @@ import org.springframework.web.server.ResponseStatusException
|
||||
import space.nstart.pcp_tgu_service.config.PlanningProperties
|
||||
import space.nstart.pcp_tgu_service.dto.CreateDraftRequest
|
||||
import space.nstart.pcp_tgu_service.dto.CreateDraftResponse
|
||||
import space.nstart.pcp_tgu_service.dto.DraftMode
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
|
||||
import space.nstart.pcp_tgu_service.integration.api.MissionCloneRequest
|
||||
@@ -45,9 +46,21 @@ class CreateDraftUseCase(
|
||||
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
|
||||
// Идемпотентность: один открытый черновик на базовый план. Повторный клик «создать черновик»
|
||||
// не плодит вторую миссию-клон и вторую DRAFT-строку.
|
||||
val existingDraft = plannedPlanRepository
|
||||
.findAllByBasePlanIdAndStatus(base.planId, PlannedPlanStatus.DRAFT)
|
||||
.firstOrNull()
|
||||
if (existingDraft != null) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"У плана ${base.planId} уже есть черновик ${existingDraft.planId} — завершите или отмените его"
|
||||
)
|
||||
}
|
||||
|
||||
return when (req.mode) {
|
||||
"replace" -> createReplaceDraft(base, now)
|
||||
"fork-forward" -> createForkForwardDraft(base, req, now)
|
||||
DraftMode.REPLACE -> createReplaceDraft(base, now)
|
||||
DraftMode.FORK_FORWARD -> createForkForwardDraft(base, req, now)
|
||||
else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown draft mode: ${req.mode}")
|
||||
}
|
||||
}
|
||||
@@ -73,7 +86,7 @@ class CreateDraftUseCase(
|
||||
|
||||
val cloneResponse = missionPlaningClient.cloneMission(
|
||||
missionId = base.planId,
|
||||
req = MissionCloneRequest(mode = "replace")
|
||||
req = MissionCloneRequest(mode = DraftMode.REPLACE)
|
||||
)
|
||||
|
||||
return saveDraftWithCompensation(
|
||||
@@ -116,7 +129,7 @@ class CreateDraftUseCase(
|
||||
|
||||
val cloneResponse = missionPlaningClient.cloneMission(
|
||||
missionId = base.planId,
|
||||
req = MissionCloneRequest(mode = "fork-forward", windowStart = windowStart, windowStop = windowStop)
|
||||
req = MissionCloneRequest(mode = DraftMode.FORK_FORWARD, windowStart = windowStart, windowStop = windowStop)
|
||||
)
|
||||
|
||||
return saveDraftWithCompensation(
|
||||
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
package space.nstart.pcp_tgu_service.service
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.mockito.ArgumentMatchers
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.times
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import space.nstart.pcp_tgu_service.config.PlanningProperties
|
||||
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
|
||||
import space.nstart.pcp_tgu_service.dto.CreateDraftRequest
|
||||
import space.nstart.pcp_tgu_service.dto.DraftMode
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
|
||||
import space.nstart.pcp_tgu_service.integration.api.MissionCloneResponse
|
||||
import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient
|
||||
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
class CreateDraftUseCaseTest {
|
||||
|
||||
private val spacecraftId = "25544"
|
||||
private val kppId = "KPP-1"
|
||||
|
||||
// --- replace ---
|
||||
|
||||
@Test
|
||||
fun `replace creates DRAFT with cloned missionId as planId, base window and basePlanId`() {
|
||||
val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN)
|
||||
val newMissionId = UUID.randomUUID()
|
||||
val fixture = fixture(base, cloneResult = newMissionId)
|
||||
|
||||
val response = fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE))
|
||||
|
||||
assertEquals(newMissionId, response.draftPlanId)
|
||||
val saved = fixture.lastSaved!!
|
||||
assertEquals(newMissionId, saved.planId)
|
||||
assertEquals(PlannedPlanStatus.DRAFT, saved.status)
|
||||
assertEquals(base.planId, saved.basePlanId)
|
||||
assertEquals(base.startTime, saved.startTime)
|
||||
assertEquals(base.endTime, saved.endTime)
|
||||
assertEquals(base.kppId, saved.kppId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replace rejects a DRAFT base`() {
|
||||
val base = plan(time(3), time(6), PlannedPlanStatus.DRAFT)
|
||||
val fixture = fixture(base)
|
||||
val ex = assertThrows<ResponseStatusException> {
|
||||
fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE))
|
||||
}
|
||||
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replace rejects a LAID_IN base`() {
|
||||
val base = plan(time(3), time(6), PlannedPlanStatus.LAID_IN)
|
||||
val fixture = fixture(base)
|
||||
val ex = assertThrows<ResponseStatusException> {
|
||||
fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE))
|
||||
}
|
||||
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replace rejects a base inside the freeze window`() {
|
||||
// startTime через 5 минут, заморозка 10 минут → точка невозврата пройдена.
|
||||
val base = plan(time(0).plusMinutes(5), time(3), PlannedPlanStatus.ACCEPTED)
|
||||
val fixture = fixture(base)
|
||||
val ex = assertThrows<ResponseStatusException> {
|
||||
fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE))
|
||||
}
|
||||
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||
verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull())
|
||||
}
|
||||
|
||||
// --- fork-forward ---
|
||||
|
||||
@Test
|
||||
fun `fork-forward creates DRAFT on a window matching the projection`() {
|
||||
val base = plan(time(0).plusMinutes(5), time(1), PlannedPlanStatus.LAID_IN)
|
||||
val windowStart = time(5)
|
||||
val windowStop = time(6)
|
||||
val newMissionId = UUID.randomUUID()
|
||||
val fixture = fixture(
|
||||
base,
|
||||
cloneResult = newMissionId,
|
||||
projection = listOf(CalculatedPlan(spacecraftId, windowStart, windowStop, kppId))
|
||||
)
|
||||
|
||||
val response = fixture.useCase.createDraft(
|
||||
base.planId,
|
||||
CreateDraftRequest(mode = DraftMode.FORK_FORWARD, windowStart = windowStart, windowStop = windowStop, windowKppId = kppId)
|
||||
)
|
||||
|
||||
assertEquals(newMissionId, response.draftPlanId)
|
||||
val saved = fixture.lastSaved!!
|
||||
assertEquals(windowStart, saved.startTime)
|
||||
assertEquals(windowStop, saved.endTime)
|
||||
assertEquals(kppId, saved.kppId)
|
||||
assertEquals(base.planId, saved.basePlanId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fork-forward requires windowStart, windowStop and windowKppId`() {
|
||||
val base = plan(time(3), time(6), PlannedPlanStatus.LAID_IN)
|
||||
val fixture = fixture(base)
|
||||
val ex = assertThrows<ResponseStatusException> {
|
||||
fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.FORK_FORWARD))
|
||||
}
|
||||
assertEquals(HttpStatus.BAD_REQUEST, ex.statusCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fork-forward rejects a windowStart not strictly in the future`() {
|
||||
val base = plan(time(3), time(6), PlannedPlanStatus.LAID_IN)
|
||||
val fixture = fixture(base)
|
||||
val ex = assertThrows<ResponseStatusException> {
|
||||
fixture.useCase.createDraft(
|
||||
base.planId,
|
||||
CreateDraftRequest(mode = DraftMode.FORK_FORWARD, windowStart = time(-1), windowStop = time(1), windowKppId = kppId)
|
||||
)
|
||||
}
|
||||
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fork-forward rejects a window that matches no future ZRV in the projection`() {
|
||||
val base = plan(time(0).plusMinutes(5), time(1), PlannedPlanStatus.LAID_IN)
|
||||
val fixture = fixture(
|
||||
base,
|
||||
projection = listOf(CalculatedPlan(spacecraftId, time(9), time(10), kppId))
|
||||
)
|
||||
val ex = assertThrows<ResponseStatusException> {
|
||||
fixture.useCase.createDraft(
|
||||
base.planId,
|
||||
CreateDraftRequest(mode = DraftMode.FORK_FORWARD, windowStart = time(5), windowStop = time(6), windowKppId = kppId)
|
||||
)
|
||||
}
|
||||
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||
verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull())
|
||||
}
|
||||
|
||||
// --- общие ---
|
||||
|
||||
@Test
|
||||
fun `unknown mode is rejected`() {
|
||||
val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN)
|
||||
val fixture = fixture(base)
|
||||
val ex = assertThrows<ResponseStatusException> {
|
||||
fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = "weird"))
|
||||
}
|
||||
assertEquals(HttpStatus.BAD_REQUEST, ex.statusCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown base plan yields 404`() {
|
||||
val fixture = fixture(base = null)
|
||||
val ex = assertThrows<ResponseStatusException> {
|
||||
fixture.useCase.createDraft(UUID.randomUUID(), CreateDraftRequest(mode = DraftMode.REPLACE))
|
||||
}
|
||||
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an existing open DRAFT on the same base yields 409`() {
|
||||
val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN)
|
||||
val existing = plan(time(3), time(6), PlannedPlanStatus.DRAFT)
|
||||
val fixture = fixture(base, existingDrafts = listOf(existing))
|
||||
val ex = assertThrows<ResponseStatusException> {
|
||||
fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE))
|
||||
}
|
||||
assertEquals(HttpStatus.CONFLICT, ex.statusCode)
|
||||
verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed DRAFT save compensates by deleting the cloned mission and rethrows`() {
|
||||
val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN)
|
||||
val newMissionId = UUID.randomUUID()
|
||||
val fixture = fixture(base, cloneResult = newMissionId, failSave = true)
|
||||
|
||||
assertThrows<RuntimeException> {
|
||||
fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE))
|
||||
}
|
||||
verify(fixture.client, times(1)).deleteMission(newMissionId)
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private class Fixture(
|
||||
val useCase: CreateDraftUseCase,
|
||||
val client: MissionPlaningClient,
|
||||
val savedHolder: Array<PlannedPlanEntity?>
|
||||
) {
|
||||
val lastSaved: PlannedPlanEntity? get() = savedHolder[0]
|
||||
}
|
||||
|
||||
private fun fixture(
|
||||
base: PlannedPlanEntity?,
|
||||
cloneResult: UUID = UUID.randomUUID(),
|
||||
projection: List<CalculatedPlan> = emptyList(),
|
||||
existingDrafts: List<PlannedPlanEntity> = emptyList(),
|
||||
failSave: Boolean = false
|
||||
): Fixture {
|
||||
val repo = mock(PlannedPlanRepository::class.java)
|
||||
val basePlanId = base?.planId ?: UUID.randomUUID()
|
||||
`when`(repo.findById(anyUuid())).thenReturn(Optional.ofNullable(base))
|
||||
`when`(repo.findAllByBasePlanIdAndStatus(basePlanId, PlannedPlanStatus.DRAFT)).thenReturn(existingDrafts)
|
||||
|
||||
val saved = arrayOfNulls<PlannedPlanEntity>(1)
|
||||
`when`(repo.save(anyNonNull<PlannedPlanEntity>())).thenAnswer { inv ->
|
||||
if (failSave) throw RuntimeException("DB down")
|
||||
val e = inv.arguments[0] as PlannedPlanEntity
|
||||
saved[0] = e
|
||||
e
|
||||
}
|
||||
|
||||
val projectionService = mock(PlanProjectionService::class.java)
|
||||
`when`(projectionService.project(spacecraftId)).thenReturn(projection)
|
||||
|
||||
val client = mock(MissionPlaningClient::class.java)
|
||||
`when`(client.cloneMission(anyUuid(), anyNonNull()))
|
||||
.thenReturn(MissionCloneResponse(newMissionId = cloneResult))
|
||||
|
||||
val useCase = CreateDraftUseCase(repo, projectionService, client, props(freeze = 10))
|
||||
return Fixture(useCase, client, saved)
|
||||
}
|
||||
|
||||
private fun plan(start: LocalDateTime, end: LocalDateTime, status: PlannedPlanStatus): PlannedPlanEntity =
|
||||
PlannedPlanEntity(
|
||||
planId = UUID.randomUUID(),
|
||||
spacecraftId = spacecraftId,
|
||||
startTime = start,
|
||||
endTime = end,
|
||||
kppId = kppId,
|
||||
status = status
|
||||
)
|
||||
|
||||
private fun props(freeze: Long): PlanningProperties =
|
||||
PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30, freeze)
|
||||
|
||||
private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong())
|
||||
|
||||
// Kotlin non-null матчеры: ArgumentMatchers.any() возвращает null и роняет NPE на non-null параметрах.
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> anyNonNull(): T { ArgumentMatchers.any<T>(); return null as T }
|
||||
private fun anyUuid(): UUID { ArgumentMatchers.any<UUID>(); return UUID.randomUUID() }
|
||||
}
|
||||
Reference in New Issue
Block a user