pcp-tgu-service: боевой POST /api/plans/{planId}/issue + гард точки невозврата
- ручная выдача вынесена из TestTguController в PlanController - assertReissuableInSlot: 422 при LAID_IN и заморозке (now >= start - layinFreezeMinutes), 409 при активной выдаче другого плана КА; перевыдача того же плана поверх своей in-flight attempt разрешена (новая attemptNo++ замещает летящую) - REISSUABLE_STATUSES удалён — гард по времени/статусу его заместил - layinFreezeMinutes (деф. 10) в PlanningProperties + config-repo/application yaml - граничные тесты в IssueDuePlanWorkerOnDemandTest, переписан PlanIssueAndDecisionFlowTest - дизайн-док версионирования (фаза 1 отмечена сделанной)
This commit is contained in:
+9
-1
@@ -46,5 +46,13 @@ data class PlanningProperties(
|
||||
* без подтверждения. По истечении план НЕ помечается ACCEPTED — он остаётся WAITING_DECISION,
|
||||
* но перестаёт блокировать выдачу следующего плана и становится новым якорем цепочки.
|
||||
*/
|
||||
val waitingDecisionGraceMinutes: Long
|
||||
val waitingDecisionGraceMinutes: Long,
|
||||
|
||||
/**
|
||||
* Порог заморозки перед стартом плана для ручной перевыдачи в свой слот (replace).
|
||||
* Если до начала плана осталось меньше этого числа минут (now >= startTime - layinFreezeMinutes),
|
||||
* план считается невозвратным: заменить его в своём слоте уже нельзя (доступен только fork-forward).
|
||||
* Вместе со статусом LAID_IN образует «точку невозврата».
|
||||
*/
|
||||
val layinFreezeMinutes: Long
|
||||
)
|
||||
|
||||
+17
-2
@@ -1,18 +1,23 @@
|
||||
package space.nstart.pcp_tgu_service.controller
|
||||
|
||||
/** Содержит методы API для чтения текущих планов. */
|
||||
/** Содержит методы API для чтения текущих планов и ручной выдачи плана. */
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import space.nstart.pcp_tgu_service.dto.PlanResponse
|
||||
import space.nstart.pcp_tgu_service.dto.ProcessStartResponse
|
||||
import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker
|
||||
import space.nstart.pcp_tgu_service.service.PlanQueryService
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/plans")
|
||||
class PlanController(
|
||||
private val planQueryService: PlanQueryService
|
||||
private val planQueryService: PlanQueryService,
|
||||
private val issueDuePlanWorker: IssueDuePlanWorker
|
||||
) {
|
||||
|
||||
/** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */
|
||||
@@ -27,4 +32,14 @@ class PlanController(
|
||||
@RequestParam(defaultValue = "7") historyDays: Long
|
||||
): List<PlanResponse> =
|
||||
planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays)
|
||||
|
||||
/**
|
||||
* Ручная выдача плана оператором («Применить / Отправить вручную»). Стартует новую attempt
|
||||
* и Camunda-процесс под гардом «точки невозврата»: план должен быть в будущем за порогом заморозки,
|
||||
* не заложен (≠ LAID_IN) и по КА не должно быть активной выдачи.
|
||||
* 404 — плана нет; 409 — по КА уже идёт выдача; 422 — точка невозврата пройдена (нужен fork-forward).
|
||||
*/
|
||||
@PostMapping("/{planId}/issue")
|
||||
fun issuePlan(@PathVariable planId: UUID): ProcessStartResponse =
|
||||
issueDuePlanWorker.issuePlanNow(planId)
|
||||
}
|
||||
|
||||
-8
@@ -12,10 +12,8 @@ import org.springframework.web.bind.annotation.RestController
|
||||
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
|
||||
import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient
|
||||
import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase
|
||||
import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker
|
||||
import space.nstart.pcp_tgu_service.service.PlanProjectionService
|
||||
import space.nstart.pcp_tgu_service.service.PlanQueryService
|
||||
import space.nstart.pcp_tgu_service.service.PlatformService
|
||||
@@ -35,7 +33,6 @@ import java.util.UUID
|
||||
class TestTguController(
|
||||
private val visibilityRefreshJobService: VisibilityRefreshJobService,
|
||||
private val planProjectionService: PlanProjectionService,
|
||||
private val issueDuePlanWorker: IssueDuePlanWorker,
|
||||
private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase,
|
||||
private val testPlanDecisionService: TestPlanDecisionService,
|
||||
private val externalPointsClient: ExternalPointsClient,
|
||||
@@ -56,11 +53,6 @@ class TestTguController(
|
||||
fun getProjection(@PathVariable spacecraftId: String): List<PlanResponse> =
|
||||
planProjectionService.project(spacecraftId).map(PlanResponse::projected)
|
||||
|
||||
/** Принудительно выдаёт конкретный persisted plan через новую attempt. */
|
||||
@PostMapping("/plans/{planId}/issue")
|
||||
fun issuePlan(@PathVariable planId: UUID): ProcessStartResponse =
|
||||
issueDuePlanWorker.issuePlanNow(planId)
|
||||
|
||||
/** Инжектит decision event через тот же use case, что Kafka consumer. */
|
||||
@PostMapping("/plan-decisions")
|
||||
fun handleDecision(@RequestBody message: PlanDecisionMessage) {
|
||||
|
||||
+45
-19
@@ -48,10 +48,14 @@ class IssueDuePlanWorker(
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only/manual hook: создаёт новую attempt для конкретного planId и стартует её. */
|
||||
/**
|
||||
* Ручная выдача конкретного persisted plan: создаёт новую attempt и стартует её.
|
||||
* Гард «точки невозврата» (см. [assertReissuableInSlot]) выполняется под lock'ом состояния КА.
|
||||
* Бросает 404, если плана нет; 409/422 — если выдать его нельзя.
|
||||
*/
|
||||
fun issuePlanNow(planId: UUID): ProcessStartResponse {
|
||||
val prepared = transactionTemplate.execute { prepareSpecificPlan(planId) }
|
||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found or cannot be issued")
|
||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found")
|
||||
return startPreparedAttempt(prepared)
|
||||
}
|
||||
|
||||
@@ -136,12 +140,7 @@ class IssueDuePlanWorker(
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null
|
||||
val state = getOrCreateLockedState(plan.spacecraftId)
|
||||
if (state.activeAttemptId != null) {
|
||||
return null
|
||||
}
|
||||
if (plan.status !in REISSUABLE_STATUSES) {
|
||||
return null
|
||||
}
|
||||
assertReissuableInSlot(plan, state, now)
|
||||
|
||||
plan.status = PlannedPlanStatus.ISSUING
|
||||
plan.updatedAt = now
|
||||
@@ -157,6 +156,44 @@ class IssueDuePlanWorker(
|
||||
return PreparedAttempt(plan.planId, attempt.attemptId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Гард ручной replace-выдачи в свой слот (см. дизайн «точка невозврата»). Бросает [ResponseStatusException],
|
||||
* если выдать план нельзя. Разрешено только когда выполнены ВСЕ условия:
|
||||
* - по КА нет активной выдачи ДРУГОГО плана (иначе 409 CONFLICT — две выдачи разом недопустимы);
|
||||
* - план не подтверждён как заложенный (`status != LAID_IN` — иначе 422, доступен только fork-forward);
|
||||
* - до старта плана больше [PlanningProperties.layinFreezeMinutes] минут (иначе 422 — окно заморожено).
|
||||
*
|
||||
* Важно: повторную ручную выдачу ТОГО ЖЕ плана разрешаем, даже если по нему уже летит attempt
|
||||
* (например, план только что ушёл в авто-выдачу за `notificationBeforeStartMinutes`). Оператор сохраняет
|
||||
* право перевыдать именно этот план вплоть до порога заморозки — новая attempt замещает текущую in-flight
|
||||
* (запоздалое решение по старой attempt запишется как stale и цепочку не тронет).
|
||||
*
|
||||
* Узкого списка «перевыдаваемых» статусов больше нет: правим в т.ч. ACCEPTED/AWAITING_LAYIN,
|
||||
* пока план в будущем за порогом и не заложен.
|
||||
*/
|
||||
private fun assertReissuableInSlot(plan: PlannedPlanEntity, state: SpacecraftPlanningStateEntity, now: LocalDateTime) {
|
||||
if (state.activeAttemptId != null && state.activePlanId != plan.planId) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"По КА ${plan.spacecraftId} уже идёт выдача другого плана (${state.activePlanId}) — дождитесь её завершения"
|
||||
)
|
||||
}
|
||||
if (plan.status == PlannedPlanStatus.LAID_IN) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"План ${plan.planId} уже заложен (LAID_IN) — перевыдача в свой слот невозможна, используйте fork-forward"
|
||||
)
|
||||
}
|
||||
val freezeAt = plan.startTime.minusMinutes(planningProperties.layinFreezeMinutes)
|
||||
if (!now.isBefore(freezeAt)) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"До старта плана ${plan.planId} осталось меньше ${planningProperties.layinFreezeMinutes} мин — " +
|
||||
"окно выдачи заморожено (точка невозврата), используйте fork-forward"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun materializeIssuingPlan(
|
||||
head: CalculatedPlan,
|
||||
state: SpacecraftPlanningStateEntity,
|
||||
@@ -255,15 +292,4 @@ class IssueDuePlanWorker(
|
||||
val planId: UUID,
|
||||
val attemptId: UUID
|
||||
)
|
||||
|
||||
private companion object {
|
||||
/** Статусы persisted-плана, который допустимо выдать вручную через issuePlanNow (test-only). */
|
||||
val REISSUABLE_STATUSES = setOf(
|
||||
PlannedPlanStatus.PLANNED,
|
||||
PlannedPlanStatus.REJECTED,
|
||||
PlannedPlanStatus.LAYIN_FAILED,
|
||||
PlannedPlanStatus.NOT_ACCEPTED,
|
||||
PlannedPlanStatus.START_AMBIGUOUS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ planning:
|
||||
reissue-prep-minutes: 30
|
||||
worker-lock-ttl-seconds: 300
|
||||
waiting-decision-grace-minutes: 30
|
||||
layin-freeze-minutes: 10
|
||||
|
||||
topics:
|
||||
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
|
||||
|
||||
+2
-1
@@ -116,6 +116,7 @@ class ExternalPointsClientTest {
|
||||
minReissueLeadMinutes = 5,
|
||||
reissuePrepMinutes = 30,
|
||||
workerLockTtlSeconds = 300,
|
||||
waitingDecisionGraceMinutes = 30
|
||||
waitingDecisionGraceMinutes = 30,
|
||||
layinFreezeMinutes = 10
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ class AcceptedPlanSlotsUpdateFlowTest {
|
||||
starter: AcceptedPlanSlotsUpdateProcessStarter
|
||||
): AcceptedPlanSlotsUpdateWorker =
|
||||
AcceptedPlanSlotsUpdateWorker(
|
||||
planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30),
|
||||
planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30, 10),
|
||||
acceptedPlanSlotsUpdateJobRepository = repository,
|
||||
acceptedPlanSlotsUpdateProcessStarter = starter,
|
||||
transactionTemplate = transactionTemplate()
|
||||
|
||||
+126
-1
@@ -1,10 +1,14 @@
|
||||
package space.nstart.pcp_tgu_service.service
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.verify
|
||||
@@ -18,6 +22,7 @@ import space.nstart.pcp_tgu_service.config.PlanningProperties
|
||||
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
|
||||
import space.nstart.pcp_tgu_service.dto.ProcessStartResponse
|
||||
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
|
||||
@@ -160,6 +165,126 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
assertEquals(attemptId, state.activeAttemptId)
|
||||
}
|
||||
|
||||
// --- Ручная выдача issuePlanNow + гард «точки невозврата» (layinFreezeMinutes = 10) ---
|
||||
|
||||
@Test
|
||||
fun `issuePlanNow issues a future not-laid-in plan past the freeze threshold`() {
|
||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
// Принятый план в будущем (старт через 30 мин > порога 10 мин), активной выдачи нет.
|
||||
val target = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
|
||||
planStore[target.planId] = target
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
|
||||
val worker = worker(planStore, attemptStore, state)
|
||||
|
||||
worker.issuePlanNow(target.planId)
|
||||
|
||||
// Выдача стартовала: новая attempt, план ушёл в WAITING_DECISION (markStarted), КА залочен.
|
||||
assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[target.planId]!!.status)
|
||||
assertEquals(1, attemptStore.size)
|
||||
assertEquals(target.planId, state.activePlanId)
|
||||
assertNotNull(state.activeAttemptId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `issuePlanNow rejects a plan inside the freeze window`() {
|
||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
// Старт через 5 мин (< порога 10 мин) → точка невозврата пройдена.
|
||||
val target = persistedPlan(PlannedPlanStatus.ACCEPTED, now().plusMinutes(5), now().plusHours(3))
|
||||
planStore[target.planId] = target
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
|
||||
val worker = worker(planStore, attemptStore, state)
|
||||
|
||||
val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(target.planId) }
|
||||
|
||||
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||
assertEquals(PlannedPlanStatus.ACCEPTED, planStore[target.planId]!!.status)
|
||||
assertTrue(attemptStore.isEmpty())
|
||||
assertNull(state.activeAttemptId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `issuePlanNow rejects a laid-in plan`() {
|
||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
// В будущем за порогом, но уже подтверждён как заложенный → только fork-forward.
|
||||
val target = persistedPlan(PlannedPlanStatus.LAID_IN, now().plusMinutes(30), now().plusHours(3))
|
||||
planStore[target.planId] = target
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
|
||||
val worker = worker(planStore, attemptStore, state)
|
||||
|
||||
val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(target.planId) }
|
||||
|
||||
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||
assertTrue(attemptStore.isEmpty())
|
||||
assertNull(state.activeAttemptId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `issuePlanNow rejects when spacecraft has an active attempt for another plan`() {
|
||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
val target = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
|
||||
planStore[target.planId] = target
|
||||
// Активная выдача идёт по ДРУГОМУ плану того же КА — две выдачи разом недопустимы.
|
||||
val state = SpacecraftPlanningStateEntity(
|
||||
spacecraftId = spacecraftId,
|
||||
activePlanId = UUID.randomUUID(),
|
||||
activeAttemptId = UUID.randomUUID()
|
||||
)
|
||||
val worker = worker(planStore, attemptStore, state)
|
||||
|
||||
val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(target.planId) }
|
||||
|
||||
assertEquals(HttpStatus.CONFLICT, ex.statusCode)
|
||||
assertTrue(attemptStore.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `issuePlanNow re-issues the same plan over its own in-flight attempt`() {
|
||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
// План уже ушёл в авто-выдачу (attempt #1 в полёте), но до старта далеко (> порога).
|
||||
val target = persistedPlan(PlannedPlanStatus.WAITING_DECISION, now().plusMinutes(30), now().plusHours(3))
|
||||
planStore[target.planId] = target
|
||||
val inFlight = PlanIssueAttemptEntity(
|
||||
attemptId = UUID.randomUUID(),
|
||||
planId = target.planId,
|
||||
attemptNo = 1,
|
||||
status = PlanIssueAttemptStatus.STARTED,
|
||||
createdAt = now(),
|
||||
updatedAt = now()
|
||||
)
|
||||
attemptStore[inFlight.attemptId] = inFlight
|
||||
val state = SpacecraftPlanningStateEntity(
|
||||
spacecraftId = spacecraftId,
|
||||
activePlanId = target.planId,
|
||||
activeAttemptId = inFlight.attemptId
|
||||
)
|
||||
val worker = worker(planStore, attemptStore, state)
|
||||
|
||||
worker.issuePlanNow(target.planId)
|
||||
|
||||
// Новая attempt #2 замещает летящую #1; план снова WAITING_DECISION, КА указывает на новую attempt.
|
||||
assertEquals(2, attemptStore.size)
|
||||
assertEquals(target.planId, state.activePlanId)
|
||||
assertNotEquals(inFlight.attemptId, state.activeAttemptId)
|
||||
assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[target.planId]!!.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `issuePlanNow returns 404 for unknown plan`() {
|
||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
|
||||
val worker = worker(planStore, attemptStore, state)
|
||||
|
||||
val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(UUID.randomUUID()) }
|
||||
|
||||
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
|
||||
}
|
||||
|
||||
private fun persistedPlan(status: PlannedPlanStatus, start: LocalDateTime, end: LocalDateTime): PlannedPlanEntity =
|
||||
PlannedPlanEntity(
|
||||
planId = UUID.randomUUID(),
|
||||
@@ -264,7 +389,7 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
private fun now(): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||
|
||||
private fun planningProperties(): PlanningProperties =
|
||||
PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30)
|
||||
PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30, 10)
|
||||
|
||||
private fun transactionTemplate(): TransactionTemplate =
|
||||
TransactionTemplate(
|
||||
|
||||
+2
-1
@@ -340,7 +340,8 @@ class PlanCalculationServiceTest {
|
||||
minReissueLeadMinutes = 5,
|
||||
reissuePrepMinutes = 30,
|
||||
workerLockTtlSeconds = 300,
|
||||
waitingDecisionGraceMinutes = 30
|
||||
waitingDecisionGraceMinutes = 30,
|
||||
layinFreezeMinutes = 10
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+5
-7
@@ -38,8 +38,9 @@ import java.util.UUID
|
||||
class PlanIssueAndDecisionFlowTest {
|
||||
|
||||
@Test
|
||||
fun `issuePlanNow creates one active attempt and repeated manual issue creates a new attempt after state cleared`() {
|
||||
fun `repeated manual issue of the same plan supersedes its in-flight attempt`() {
|
||||
val spacecraftId = "25544"
|
||||
// Старт через 12 часов — далеко за порогом заморозки, перевыдача того же плана разрешена.
|
||||
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.PLANNED)
|
||||
val planStore = linkedMapOf(plan.planId to plan)
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
@@ -47,17 +48,14 @@ class PlanIssueAndDecisionFlowTest {
|
||||
val worker = issueWorker(planStore, attemptStore, state)
|
||||
|
||||
val firstResponse = worker.issuePlanNow(plan.planId)
|
||||
val blocked = runCatching { worker.issuePlanNow(plan.planId) }.exceptionOrNull()
|
||||
state.activePlanId = null
|
||||
state.activeAttemptId = null
|
||||
plan.status = PlannedPlanStatus.REJECTED
|
||||
// Тот же план, attempt #1 ещё «в полёте» — ручная перевыдача не блокируется, а замещает её.
|
||||
val secondResponse = worker.issuePlanNow(plan.planId)
|
||||
|
||||
assertEquals(1, attemptStore.getValue(firstResponse.attemptId).attemptNo)
|
||||
assertEquals(2, attemptStore.getValue(secondResponse.attemptId).attemptNo)
|
||||
assertNotEquals(firstResponse.attemptId, secondResponse.attemptId)
|
||||
assertEquals(secondResponse.attemptId, state.activeAttemptId)
|
||||
assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status)
|
||||
assertTrue(blocked?.message?.startsWith("404 NOT_FOUND") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -333,7 +331,7 @@ class PlanIssueAndDecisionFlowTest {
|
||||
private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong())
|
||||
|
||||
private fun planningProperties(): PlanningProperties =
|
||||
PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30)
|
||||
PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30, 10)
|
||||
|
||||
private fun transactionTemplate(): TransactionTemplate =
|
||||
TransactionTemplate(
|
||||
|
||||
Reference in New Issue
Block a user