diff --git a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md index 3007b87..a33f765 100644 --- a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md +++ b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md @@ -248,8 +248,15 @@ P2 = DRAFT (base_plan_id=P0, окно=W) - **Приёмка:** `replace`-клон копирует все режимы; `fork-forward` — только пересёкшиеся; `DRAFT` не виден как запланированный слот; сбой clone не оставляет осиротыша. - **Тесты:** предикат перекрытия (границы окна), фильтр `DRAFT` в проекции/запросе. -### Фаза 3 — Правки контента в черновик +### Фаза 3 — Правки контента в черновик — ✅ СДЕЛАНО **Модель: Sonnet.** + +> **Реализовано:** +> - `PATCH /api/missions/survey-modes/{modeId}` в [MissionController](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt) → [updateSurveyRoute](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt): перестраивает контур существующей съёмки тем же `buildContour`, что и `/survey-route/build`, и сохраняет на месте (timeStart/revolution/lat/long/duration/contourWkt/roll). Источник переводится в `MANUAL` (правка переживает пересчёт), статус сохраняется. Нет режима → 404. Реюзит `SurveyRouteBuildRequestDTO`. +> - `DELETE /api/missions/modes/{modeId}` → [deleteMode](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt): диспатч SURVEY/DROP. Съёмка — снимаются брони слотов (`mode_booking`) и связи `drop_mode_survey`; сброс, оставшийся без единой съёмки, удаляется (осиротевший); другие съёмки того же сброса не трогаются. Сброс — удаляются только его связи и он сам, съёмки независимы. Нет режима → 404. +> - `saveSurveyRoute`/`saveDropRoute` больше **не создают живую миссию на лету** — адресуется только существующая миссия-черновик; нет миссии → 404. В `saveSurveyRoute` проверка миссии вынесена **до** дорогого `buildContour` (fail-fast). Swagger-описания и kdoc обновлены. +> - Репозиторий: `DropModeSurveyRepository.findAllBySurveyMode_Id` (поиск связей по съёмке). +> - Тесты: `MissionEditModesTest` (6) — каскады DELETE (осиротевший сброс удаляется; общий сброс с другой съёмкой остаётся; удаление сброса не трогает съёмки; 404 на неизвестный режим) + 404 на `saveSurveyRoute`/`saveDropRoute` без миссии. Весь модуль зелёный. - `pcp-mission-planing-service`: `PATCH /api/missions/survey-modes/{modeId}` (перестроение контура по новому окну/крену тем же алгоритмом, что `survey-route/build`), `DELETE /api/missions/modes/{modeId}`. - Переключить [saveSurveyRoute/saveDropRoute](services/pcp-tgu-service/../../../pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt#L61) на адресацию **DRAFT-миссии**; убрать авто-создание живой миссии для editor-сценария. - **Приёмка:** правка/удаление затрагивают только `DRAFT`; живая миссия неизменна; контур пересчитан корректно; каскады при `DELETE` (связь `drop_mode ↔ survey`) целы. diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt index defe2fc..c550475 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt @@ -12,6 +12,7 @@ import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PutMapping @@ -51,7 +52,7 @@ class MissionController( description = "Строит контур полосы съёмки тем же алгоритмом, что и /survey-route/build, " + "и сохраняет его в БД как режим съёмки (source = MANUAL) под основным планом миссии. " + "planId — идентификатор выбранного плана в pcp-tgu-service, совпадает с mission_id этого сервиса. " + - "Если миссии/плана с таким id ещё нет, они создаются на лету по параметрам запроса." + "Адресуется существующая миссия-черновик (DRAFT-клон); если миссии с таким id нет — 404." ) @ApiResponses( value = [ @@ -70,7 +71,7 @@ class MissionController( description = "Сохраняет режим сброса (DROP) под основным планом миссии по выбранной оператором ЗРВ " + "и набору маршрутов съёмки, заканчивающихся строго до начала зоны. " + "planId — идентификатор выбранного плана в pcp-tgu-service (= mission_id). " + - "Если миссии/плана с таким id ещё нет, они создаются на лету по параметрам запроса." + "Адресуется существующая миссия-черновик (DRAFT-клон); если миссии с таким id нет — 404." ) @ApiResponses( value = [ @@ -211,6 +212,46 @@ class MissionController( return ResponseEntity.noContent().build() } + @Operation( + summary = "Перестроить маршрут съёмки", + description = "Перестраивает контур существующего режима съёмки по новым параметрам (окно/крен/" + + "угол захвата) тем же алгоритмом, что и /survey-route/build, и сохраняет результат на месте. " + + "Используется для правки включения в плане-черновике." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Маршрут перестроен"), + ApiResponse(responseCode = "404", description = "Режим съёмки не найден") + ] + ) + @PatchMapping("/survey-modes/{mode_id}") + fun updateSurveyRoute( + @Parameter(description = "Идентификатор режима съёмки") + @PathVariable("mode_id") modeId: Long, + @Valid @RequestBody body: SurveyRouteBuildRequestDTO + ) = missionPlaningService.updateSurveyRoute(modeId, body) + + @Operation( + summary = "Удалить включение (съёмку или сброс)", + description = "Удаляет режим (SURVEY или DROP) из плана-черновика по его id. Для съёмки снимаются " + + "брони слотов и связи со сбросами, осиротевший сброс (без единой съёмки) тоже удаляется. " + + "Для сброса удаляются только его связи; съёмки сохраняются." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "Включение удалено"), + ApiResponse(responseCode = "404", description = "Режим не найден") + ] + ) + @DeleteMapping("/modes/{mode_id}") + fun deleteMode( + @Parameter(description = "Идентификатор режима (survey или drop)") + @PathVariable("mode_id") modeId: Long + ): ResponseEntity { + missionPlaningService.deleteMode(modeId) + return ResponseEntity.noContent().build() + } + @Operation(summary = "Создать миссию") diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/DropModeSurveyRepository.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/DropModeSurveyRepository.kt index 45b3850..a14622f 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/DropModeSurveyRepository.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/DropModeSurveyRepository.kt @@ -7,4 +7,7 @@ import java.util.UUID interface DropModeSurveyRepository : JpaRepository { fun findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropModeId: Long): List fun findAllByDropMode_Plan_Mission_MissionId(missionId: UUID): List + + /** Связи сброс↔съёмка по конкретной съёмке (для удаления съёмки и поиска осиротевших сбросов). */ + fun findAllBySurveyMode_Id(surveyModeId: Long): List } diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt index 1076648..a8eac66 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt @@ -664,42 +664,18 @@ class MissionPlaningService( * [SurveyModeEntity] с источником [SurveyModeSource.MANUAL] под основным планом миссии * (planNumber = 1) — той же таблицей `survey_modes`, что и при штатном расчёте плана. * - * Если миссии/плана с таким идентификатором ещё нет (план не рассчитывался), они - * создаются на лету по параметрам запроса: `missionId = planId`, `satelliteId` из запроса - * (NORAD), интервал — окно построенного маршрута. При последующем штатном расчёте - * [loadSlots] интервал миссии будет уточнён. + * Адресуется только **существующая** миссия-черновик: правки редактора идут в DRAFT-клон, + * созданный заранее (`POST /api/missions/{id}/clone`). Авто-создание живой миссии на лету + * убрано — нельзя молча писать правки в несуществующий/живой план. Если миссии с таким id + * нет — 404. * * Ручные маршруты (source = MANUAL) переживают пересчёт плана: [loadSlots] удаляет только * пересчитываемые маршруты ([ModeRepository.deleteRecalculableSurveyModesByPlanId]). */ @Transactional fun saveSurveyRoute(planId: UUID, req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO { - val built = buildContour(req) - - val mission = missionRepository.findById(planId).orElseGet { - // Интервал плана берём из запроса (фронт знает окно выбранного плана). Если не передан — - // фолбэк на окно самого маршрута; при штатном расчёте [loadSlots] интервал будет уточнён. - val routeStop = built.timeStart.plusNanos((built.durationSeconds * 1_000_000_000L).toLong()) - val missionStart = req.planStart ?: built.timeStart - val missionStop = req.planEnd ?: routeStop - logger.info( - "Миссия/план {} не найдены — создаём под ручной маршрут: satelliteId={}, interval=[{} - {}] (источник интервала: {})", - planId, - req.satelliteId, - missionStart, - missionStop, - if (req.planStart != null && req.planEnd != null) "план" else "окно маршрута" - ) - missionRepository.save( - MissionEntity( - missionId = planId, - satelliteId = req.satelliteId, - missionStart = missionStart, - missionStop = missionStop, - status = "NEW" - ) - ) - } + // Проверяем существование миссии-черновика до дорогого расчёта контура (fail-fast на 404). + val mission = missionRepository.findById(planId).orElseThrow { notFound(planId) } val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1) .orElseGet { @@ -713,6 +689,8 @@ class MissionPlaningService( ) } + val built = buildContour(req) + val saved = surveyModeRepository.save( SurveyModeEntity( plan = plan, @@ -750,8 +728,8 @@ class MissionPlaningService( * ([calculateDrops]). * * Идентификатор плана (`planId`) совпадает с `MissionEntity.missionId` (см. saveSurveyRoute). - * Если миссии/плана с таким id ещё нет — создаются на лету по параметрам запроса (окно плана - * из `planStart/planEnd`, фолбэк — окно зоны). + * Адресуется только **существующая** миссия-черновик (DRAFT-клон, созданный заранее). Авто-создание + * живой миссии на лету убрано — если миссии с таким id нет, возвращается 404. * * Внимание: ручные сбросы НЕ защищены от пересчёта — штатный [calculateDrops] удаляет все * сбросы плана ([ModeRepository.deleteDropModesByPlanId]). @@ -762,27 +740,7 @@ class MissionPlaningService( throw CustomErrorException("Не выбрано ни одного маршрута съёмки для сброса") } - val mission = missionRepository.findById(planId).orElseGet { - val missionStart = req.planStart ?: req.zoneStart - val missionStop = req.planEnd ?: req.zoneStop - logger.info( - "Миссия/план {} не найдены — создаём под ручной сброс: satelliteId={}, interval=[{} - {}] (источник интервала: {})", - planId, - req.satelliteId, - missionStart, - missionStop, - if (req.planStart != null && req.planEnd != null) "план" else "окно зоны" - ) - missionRepository.save( - MissionEntity( - missionId = planId, - satelliteId = req.satelliteId, - missionStart = missionStart, - missionStop = missionStop, - status = "NEW" - ) - ) - } + val mission = missionRepository.findById(planId).orElseThrow { notFound(planId) } val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1) .orElseGet { @@ -856,6 +814,102 @@ class MissionPlaningService( ) } + /** + * Перестраивает контур существующего маршрута съёмки по новым параметрам (окно/крен/угол захвата) + * тем же алгоритмом [buildContour], что и [buildSurveyRoute]/[saveSurveyRoute], и сохраняет + * результат на месте (правка включения в черновике). + * + * Меняются: время начала, виток, центр (lat/long), длительность, контур (WKT) и крен. Статус + * сохраняется. Источник переводится в [SurveyModeSource.MANUAL] — отредактированный оператором + * маршрут переживает пересчёт плана ([loadSlots] удаляет только пересчитываемые, не MANUAL). + */ + @Transactional + fun updateSurveyRoute(modeId: Long, req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO { + val survey = surveyModeRepository.findById(modeId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Survey mode with id $modeId not found") + } + + val built = buildContour(req) + survey.timeStart = built.timeStart.atOffset(ZoneOffset.UTC) + survey.revolution = built.revolution + survey.lat = built.lat + survey.long = built.longitude + survey.duration = built.durationSeconds + survey.contourWkt = built.contourWkt + survey.roll = built.roll + survey.source = SurveyModeSource.MANUAL + val saved = surveyModeRepository.save(survey) + + logger.info( + "Перестроен маршрут съёмки (survey_mode id={}): revolution={}, timeStart={}, duration={}, roll={}", + saved.id, built.revolution, built.timeStart, built.durationSeconds, built.roll + ) + + return built.toResponse(surveyModeId = saved.id) + } + + /** + * Удаляет включение (SURVEY или DROP) из плана-черновика по его id. + * + * Съёмка: снимаются её брони слотов ([ModeBookingEntity]) и связи съёмка↔сброс + * ([DropModeSurveyEntity]); сброс, оставшийся без единой съёмки, тоже удаляется (осиротевший + * сеанс не имеет смысла). Другие съёмки того же сброса не трогаются. + * + * Сброс: удаляются только его связи и сам сброс; съёмки независимы и сохраняются. + */ + @Transactional + fun deleteMode(modeId: Long) { + val mode = modeRepository.findById(modeId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Mode with id $modeId not found") + } + when (mode) { + is SurveyModeEntity -> deleteSurveyMode(mode) + is DropModeEntity -> deleteDropMode(mode) + else -> throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Unsupported mode type for id $modeId: ${mode::class.qualifiedName}" + ) + } + } + + private fun deleteSurveyMode(survey: SurveyModeEntity) { + val surveyId = survey.id ?: throw IllegalStateException("Survey mode id is null") + + val bookings = modeBookingRepository.findAllByMode_IdIn(listOf(surveyId)) + if (bookings.isNotEmpty()) { + modeBookingRepository.deleteAll(bookings) + } + + val links = dropModeSurveyRepository.findAllBySurveyMode_Id(surveyId) + val affectedDropIds = links.mapNotNull { it.dropMode?.id }.distinct() + if (links.isNotEmpty()) { + dropModeSurveyRepository.deleteAll(links) + } + // Сброс, у которого после удаления этой съёмки не осталось ни одной другой, — осиротел. + affectedDropIds.forEach { dropId -> + val hasOtherSurvey = dropModeSurveyRepository + .findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropId) + .any { it.surveyMode?.id != null && it.surveyMode?.id != surveyId } + if (!hasOtherSurvey) { + dropModeRepository.deleteById(dropId) + } + } + + surveyModeRepository.delete(survey) + logger.info("Удалена съёмка (survey_mode id={}), снято броней={}, осиротевших сбросов проверено={}", + surveyId, bookings.size, affectedDropIds.size) + } + + private fun deleteDropMode(drop: DropModeEntity) { + val dropId = drop.id ?: throw IllegalStateException("Drop mode id is null") + val links = dropModeSurveyRepository.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropId) + if (links.isNotEmpty()) { + dropModeSurveyRepository.deleteAll(links) + } + dropModeRepository.delete(drop) + logger.info("Удалён сброс (drop_mode id={}), снято связей со съёмками={}", dropId, links.size) + } + /** * Строит контур полосы съёмки по параметрам видимости точки. * Чистый расчёт без персистенции; переиспользуется [buildSurveyRoute] и [saveSurveyRoute]. diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt new file mode 100644 index 0000000..34b9916 --- /dev/null +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt @@ -0,0 +1,236 @@ +package space.nstart.pcp.pcp_mission_planing_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.ModeBookingEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.ModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.PlanEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource +import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeSurveyRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.MissionRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.ModeBookingRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.ModeRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.PlanRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.SurveyModeRepository +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.DefaultSatelliteMissionType +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.EmissioSatelliteMissionType +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.ReflixioSatelliteMissionType +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory +import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropRouteSaveRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.UUID + +class MissionEditModesTest { + + private val surveyStore = linkedMapOf() + private val dropStore = linkedMapOf() + private val dropSurveyStore = mutableListOf() + private val bookingStore = mutableListOf() + + private var autoId = 10L + + private val missionRepository = mock(MissionRepository::class.java) + + private val modeRepository = mock(ModeRepository::class.java).also { repo -> + `when`(repo.findById(anyLong())).thenAnswer { inv -> + val id = inv.arguments[0] as Long + java.util.Optional.ofNullable(surveyStore[id] ?: dropStore[id]) + } + } + + private val surveyModeRepository = mock(SurveyModeRepository::class.java).also { repo -> + doAnswer { inv -> + val s = inv.arguments[0] as SurveyModeEntity + surveyStore.remove(s.id) + null + }.`when`(repo).delete(anyNonNull()) + } + + private val dropModeRepository = mock(DropModeRepository::class.java).also { repo -> + doAnswer { inv -> + val d = inv.arguments[0] as DropModeEntity + dropStore.remove(d.id) + null + }.`when`(repo).delete(anyNonNull()) + doAnswer { inv -> + dropStore.remove(inv.arguments[0] as Long) + null + }.`when`(repo).deleteById(anyLong()) + } + + private val dropModeSurveyRepository = mock(DropModeSurveyRepository::class.java).also { repo -> + `when`(repo.findAllBySurveyMode_Id(anyLong())).thenAnswer { inv -> + val sid = inv.arguments[0] as Long + dropSurveyStore.filter { it.surveyMode?.id == sid } + } + `when`(repo.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(anyLong())).thenAnswer { inv -> + val did = inv.arguments[0] as Long + dropSurveyStore.filter { it.dropMode?.id == did } + } + doAnswer { inv -> + @Suppress("UNCHECKED_CAST") + val links = inv.arguments[0] as Iterable + dropSurveyStore.removeAll(links.toSet()) + null + }.`when`(repo).deleteAll(anyNonNull()) + } + + private val modeBookingRepository = mock(ModeBookingRepository::class.java).also { repo -> + `when`(repo.findAllByMode_IdIn(anyNonNull())).thenAnswer { inv -> + @Suppress("UNCHECKED_CAST") + val ids = inv.arguments[0] as Collection + bookingStore.filter { it.mode?.id in ids } + } + doAnswer { inv -> + @Suppress("UNCHECKED_CAST") + val bs = inv.arguments[0] as Iterable + bookingStore.removeAll(bs.toSet()) + null + }.`when`(repo).deleteAll(anyNonNull()) + } + + private val service = MissionPlaningService( + missionRepository = missionRepository, + modeRepository = modeRepository, + planRepository = mock(PlanRepository::class.java), + surveyModeRepository = surveyModeRepository, + dropModeRepository = dropModeRepository, + dropModeSurveyRepository = dropModeSurveyRepository, + modeBookingRepository = modeBookingRepository, + bookedSlotsEventPublisher = mock(BookedSlotsEventPublisher::class.java), + ballisticsService = mock(BallisticsService::class.java), + complexMissionService = mock(ComplexMissionService::class.java), + satelliteCatalogClient = mock(space.nstart.pcp.pcp_mission_planing_service.service.catalog.SatelliteCatalogClient::class.java), + satelliteMissionTypeFactory = SatelliteMissionTypeFactory( + listOf(DefaultSatelliteMissionType(), EmissioSatelliteMissionType(), ReflixioSatelliteMissionType()), + DefaultSatelliteMissionType() + ) + ) + + private val plan = PlanEntity(id = 1L, planNumber = 1) + + // --- DELETE mode --- + + @Test + fun `deleting a survey removes its bookings, links and the orphaned drop`() { + val survey = survey() + val drop = drop() + link(drop, survey) + booking(survey) + + service.deleteMode(survey.id!!) + + assertFalse(surveyStore.containsKey(survey.id)) + assertTrue(bookingStore.isEmpty()) + assertFalse(dropStore.containsKey(drop.id), "сброс без единственной съёмки должен быть удалён") + assertTrue(dropSurveyStore.isEmpty()) + } + + @Test + fun `deleting a survey keeps a drop still referenced by another survey`() { + val survey1 = survey() + val survey2 = survey() + val drop = drop() + link(drop, survey1) + link(drop, survey2) + + service.deleteMode(survey1.id!!) + + assertFalse(surveyStore.containsKey(survey1.id)) + assertTrue(surveyStore.containsKey(survey2.id)) + assertTrue(dropStore.containsKey(drop.id), "сброс с оставшейся съёмкой не удаляется") + assertEquals(1, dropSurveyStore.size) + assertEquals(survey2.id, dropSurveyStore.single().surveyMode?.id) + } + + @Test + fun `deleting a drop removes the drop and its links but keeps surveys`() { + val survey = survey() + val drop = drop() + link(drop, survey) + + service.deleteMode(drop.id!!) + + assertFalse(dropStore.containsKey(drop.id)) + assertTrue(dropSurveyStore.isEmpty()) + assertTrue(surveyStore.containsKey(survey.id), "съёмка независима от сброса и сохраняется") + } + + @Test + fun `deleting an unknown mode yields 404`() { + val ex = assertThrows { service.deleteMode(999L) } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + // --- save routes больше не создают живую миссию --- + + @Test + fun `saveSurveyRoute on a missing mission yields 404 instead of auto-creating`() { + // missionRepository не застаблен → findById возвращает пустой Optional. + val ex = assertThrows { + service.saveSurveyRoute(UUID.randomUUID(), SurveyRouteBuildRequestDTO(satelliteId = 25544)) + } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + @Test + fun `saveDropRoute on a missing mission yields 404 instead of auto-creating`() { + val ex = assertThrows { + service.saveDropRoute( + UUID.randomUUID(), + DropRouteSaveRequestDTO(satelliteId = 25544, surveyModeIds = listOf(1L)) + ) + } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + // --- helpers --- + + private fun survey(): SurveyModeEntity { + val id = ++autoId + val s = SurveyModeEntity( + id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 12, 0).atOffset(ZoneOffset.UTC), + revolution = id, lat = 55.0, long = 37.0, duration = 60.0, roll = 0.0, + status = SurveyModeStatus.PLANNED, source = SurveyModeSource.MANUAL + ) + surveyStore[id] = s + return s + } + + private fun drop(): DropModeEntity { + val id = ++autoId + val d = DropModeEntity( + id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 13, 0).atOffset(ZoneOffset.UTC), + revolution = id, station = "102", duration = 60.0 + ) + dropStore[id] = d + return d + } + + private fun link(drop: DropModeEntity, survey: SurveyModeEntity) { + dropSurveyStore.add(DropModeSurveyEntity(dropMode = drop, surveyMode = survey)) + } + + private fun booking(survey: SurveyModeEntity) { + bookingStore.add(ModeBookingEntity(id = ++autoId, mode = survey, slotId = autoId)) + } + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { org.mockito.ArgumentMatchers.any(); return null as T } + private fun anyLong(): Long { org.mockito.ArgumentMatchers.anyLong(); return 0L } +}