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:
Дмитрий Соловьев
2026-06-03 13:09:52 +03:00
parent 0d69617420
commit 85ce238f52
8 changed files with 347 additions and 11 deletions
@@ -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,
@@ -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,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>
@@ -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(
@@ -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() }
}