Перевод pcp-tgu на новый НСИ

This commit is contained in:
Дмитрий Соловьев
2026-05-26 11:07:37 +03:00
parent 1ef912be66
commit cbbafebf79
15 changed files with 462 additions and 298 deletions
+4 -1
View File
@@ -65,7 +65,10 @@ planning:
external-update-enabled: false external-update-enabled: false
classifier: classifier:
platforms-url: http://pcp-satellite-catalog-service:${pcp.ports.satellite-catalog} platforms-url: https://ordinis.nstart.cloud/api/v1/spacecraft/records
allowed-platform-statuses:
- OPERATIONAL
- STANDBY
tle: tle:
polling: polling:
+4 -1
View File
@@ -38,7 +38,10 @@ topics:
spacecraft-points-update: pcp.tgu.kpp-update.v1 spacecraft-points-update: pcp.tgu.kpp-update.v1
classifier: classifier:
platforms-url: http://nsi-classifier-api.k8s.265.nstart.local/classifiers/platforms platforms-url: https://ordinis.nstart.cloud/api/v1/spacecraft/records
allowed-platform-statuses:
- OPERATIONAL
- STANDBY
--- ---
spring: spring:
File diff suppressed because one or more lines are too long
@@ -9,5 +9,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties
data class ClassifierProperties( data class ClassifierProperties(
/** URL классификатора космических аппаратов. */ /** URL классификатора космических аппаратов. */
val platformsUrl: String val platformsUrl: String,
/** Статусы КА из НСИ, с которыми сервису разрешено работать. */
val allowedPlatformStatuses: Set<String>
) )
@@ -1,7 +1,8 @@
package space.nstart.pcp_tgu_service.controller package space.nstart.pcp_tgu_service.controller
/** Содержит методы API только на чтение для платформ из внешнего классификатора. */ /** Содержит методы API только на чтение для платформ из внешнего классификатора. */
import space.nstart.pcp_tgu_service.dto.ContentItemDTO import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem
import space.nstart.pcp_tgu_service.dto.PlatformResponse
import space.nstart.pcp_tgu_service.service.PlatformService import space.nstart.pcp_tgu_service.service.PlatformService
import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag import io.swagger.v3.oas.annotations.tags.Tag
@@ -25,14 +26,24 @@ class PlatformController(
/** /**
* Возвращает список космических аппаратов из внешнего классификатора. * Возвращает список космических аппаратов из внешнего классификатора.
*/ */
/** Возвращает платформы с необязательной фильтрацией по типу и статусу. */
@GetMapping @GetMapping
@Operation(summary = "Получить список космических аппаратов") @Operation(summary = "Получить список космических аппаратов")
fun getPlatforms( fun getPlatforms(
@RequestParam(required = false) platformTypeCode: String?,
@RequestParam(required = false) status: String? @RequestParam(required = false) status: String?
): Mono<List<ContentItemDTO>> = ): Mono<List<PlatformResponse>> =
Mono.fromCallable { Mono.fromCallable {
platformService.loadPlatforms(platformTypeCode, status) platformService.loadPlatforms(status).map { platform -> platform.toResponse() }
}.subscribeOn(Schedulers.boundedElastic()) }.subscribeOn(Schedulers.boundedElastic())
private fun PlatformCatalogItem.toResponse(): PlatformResponse =
PlatformResponse(
id = nsiId,
businessKey = businessKey,
name = name,
status = status,
noradId = noradId,
mission = mission,
validFrom = validFrom,
validTo = validTo
)
} }
@@ -0,0 +1,15 @@
package space.nstart.pcp_tgu_service.domain
import java.time.Instant
/** Платформа КА из НСИ в минимальном виде, нужном pcp-tgu-service. */
data class PlatformCatalogItem(
val nsiId: String,
val businessKey: String?,
val name: String?,
val status: String?,
val noradId: Long?,
val mission: String?,
val validFrom: Instant?,
val validTo: Instant?
)
@@ -2,249 +2,35 @@ package space.nstart.pcp_tgu_service.dto
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.ObjectMapper import java.time.Instant
/** /** Платформа из root-array ответа НСИ. */
* Корневой DTO JSON-ответа.
* Соответствует всему объекту JSON.
*/
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class PlatformsResponseDTO( data class NsiPlatformDto(
val id: String?,
/** val businessKey: String?,
* UUID справочника. val data: NsiPlatformDataDto?,
*/ val validFrom: Instant?,
val uuid: String?, val validTo: Instant?
/**
* API имя сущности.
*/
val apiName: String?,
/**
* GUI имя сущности.
*/
val guiName: String?,
/**
* Статус справочника.
*/
val status: String?,
/**
* Признак иерархии.
*/
val hierarchy: Boolean?,
/**
* Правило валидации.
*/
val validationRule: String?,
/**
* Список описаний полей справочника.
*/
val fields: List<FieldDTO>?,
/**
* Основной список элементов.
*/
val content: List<ContentItemDTO>?,
/**
* Информация о пагинации.
*/
val page: PageDTO?
) )
/** /** Согласованные поля объекта data платформы НСИ. */
* DTO описания одного поля.
*/
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class FieldDTO( data class NsiPlatformDataDto(
val name: String?,
/**
* UUID поля.
*/
val uuid: String?,
/**
* API имя поля.
*/
val apiName: String?,
/**
* GUI имя поля.
*/
val guiName: String?,
/**
* Статус поля.
*/
val status: String?, val status: String?,
/**
* Тип поля.
*/
val type: String?,
/**
* API имя связанного классификатора.
*/
val fkClassifierApiName: String?,
/**
* API имя связанного поля.
*/
val fkFieldApiName: String?
)
/**
* DTO одного элемента массива content.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class ContentItemDTO(
/**
* UUID записи.
*/
val uuid: String?,
/**
* UUID родительского элемента.
*/
val parentItemUuid: String?,
/**
* Статус записи.
*/
val status: String?,
/**
* Дата начала действия.
*/
val initiation: String?,
/**
* Дата окончания действия.
*/
val expiration: String?,
/**
* Вложенный объект данных.
*/
val data: ContentDataDTO?
)
/**
* DTO вложенного объекта data внутри content.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class ContentDataDTO(
/**
* Код типа платформы.
*/
@param:JsonProperty("platform_type_code")
val platformTypeCode: String?,
/**
* NORAD ID.
*/
@param:JsonProperty("norad_id") @param:JsonProperty("norad_id")
val noradId: Long?, val noradId: Long?,
val mission: String?
)
/** data class PlatformResponse(
* Уникальный номер изделия (УМН). val id: String,
*/ val businessKey: String?,
val umn: String?,
/**
* Название аппарата.
*/
val name: String?, val name: String?,
val status: String?,
/** val noradId: Long?,
* Описание аппарата. val mission: String?,
*/ val validFrom: Instant?,
val description: String?, val validTo: Instant?
/**
* Название типа платформы.
*/
@param:JsonProperty("platform_type_name")
val platformTypeName: String?
) )
/**
* DTO блока page (пагинация).
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class PageDTO(
/**
* Размер страницы.
*/
val size: Int?,
/**
* Номер страницы.
*/
val number: Int?,
/**
* Общее количество элементов.
*/
val totalElements: Long?,
/**
* Общее количество страниц.
*/
val totalPages: Int?
)
/**
* Класс парсера JSON.
*/
class PlatformsJsonParser(
/**
* ObjectMapper Jackson.
* Предоставляется Spring Boot автоматически.
*/
private val objectMapper: ObjectMapper
) {
/**
* Парсит весь JSON в корневой DTO.
*
* @param json JSON строка
* @return PlatformsResponseDTO
*/
fun parse(json: String): PlatformsResponseDTO {
/**
* Десериализация JSON -> Kotlin объект.
*/
return objectMapper.readValue(json, PlatformsResponseDTO::class.java)
}
/**
* Парсит JSON и возвращает только массив content.
*
* @param json JSON строка
* @return список элементов content
*/
fun parseContent(json: String): List<ContentItemDTO> {
/**
* Парсим корневой объект.
*/
val response = objectMapper.readValue(json, PlatformsResponseDTO::class.java)
/**
* Если content отсутствует — возвращаем пустой список.
*/
return response.content.orEmpty()
}
}
@@ -5,8 +5,8 @@ import space.nstart.pcp_tgu_service.config.ExternalApiProperties
import space.nstart.pcp_tgu_service.config.PlanningProperties import space.nstart.pcp_tgu_service.config.PlanningProperties
import space.nstart.pcp_tgu_service.domain.InsertionPoint import space.nstart.pcp_tgu_service.domain.InsertionPoint
import space.nstart.pcp_tgu_service.domain.ObservationWindow import space.nstart.pcp_tgu_service.domain.ObservationWindow
import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem
import space.nstart.pcp_tgu_service.domain.SpacecraftPoints import space.nstart.pcp_tgu_service.domain.SpacecraftPoints
import space.nstart.pcp_tgu_service.dto.ContentItemDTO
import space.nstart.pcp_tgu_service.dto.ExternalRvaItemDto import space.nstart.pcp_tgu_service.dto.ExternalRvaItemDto
import space.nstart.pcp_tgu_service.service.PlatformService import space.nstart.pcp_tgu_service.service.PlatformService
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@@ -39,16 +39,15 @@ class ExternalPointsClient(
/** /**
* Загружает окна видимости по всем КА из классификатора, * Загружает окна видимости по всем КА из классификатора,
* у которых заполнен NORAD ID. * которые разрешены для работы TGU.
*/ */
/** Загружает окна видимости для всех КА с заполненным NORAD-идентификатором в классификаторе. */
fun fetchAllSpacecraftPoints(): List<SpacecraftPoints> { fun fetchAllSpacecraftPoints(): List<SpacecraftPoints> {
val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES) val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES)
val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays) val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays)
return platformService.loadPlatforms() return platformService.loadAllowedPlatforms()
.mapNotNull { platform -> .mapNotNull { platform ->
val noradId = platform.data?.noradId ?: return@mapNotNull null val noradId = requireNotNull(platform.noradId)
fetchSpacecraftPointsSafely( fetchSpacecraftPointsSafely(
platform = platform, platform = platform,
satelliteId = noradId, satelliteId = noradId,
@@ -60,7 +59,7 @@ class ExternalPointsClient(
/** Оборачивает загрузку по одному КА и подавляет транспортные ошибки только для него. */ /** Оборачивает загрузку по одному КА и подавляет транспортные ошибки только для него. */
private fun fetchSpacecraftPointsSafely( private fun fetchSpacecraftPointsSafely(
platform: ContentItemDTO, platform: PlatformCatalogItem,
satelliteId: Long, satelliteId: Long,
timeStart: LocalDateTime, timeStart: LocalDateTime,
timeStop: LocalDateTime timeStop: LocalDateTime
@@ -138,19 +137,19 @@ class ExternalPointsClient(
} }
private fun logExternalApiError( private fun logExternalApiError(
platform: ContentItemDTO, platform: PlatformCatalogItem,
satelliteId: Long, satelliteId: Long,
timeStart: LocalDateTime, timeStart: LocalDateTime,
timeStop: LocalDateTime, timeStop: LocalDateTime,
ex: WebClientException ex: WebClientException
) { ) {
val platformName = platform.data?.name
if (ex is WebClientResponseException) { if (ex is WebClientResponseException) {
log.warn( log.warn(
"Skipping spacecraft due to external API response error. name={}, noradId={}, timeStart={}, timeStop={}, status={}, responseBody={}", "Skipping spacecraft due to external API response error. name={}, noradId={}, nsiId={}, businessKey={}, timeStart={}, timeStop={}, status={}, responseBody={}",
platformName, platform.name,
satelliteId, satelliteId,
platform.nsiId,
platform.businessKey,
timeStart, timeStart,
timeStop, timeStop,
ex.statusCode.value(), ex.statusCode.value(),
@@ -160,9 +159,11 @@ class ExternalPointsClient(
} }
log.warn( log.warn(
"Skipping spacecraft due to external API transport error. name={}, noradId={}, timeStart={}, timeStop={}, message={}", "Skipping spacecraft due to external API transport error. name={}, noradId={}, nsiId={}, businessKey={}, timeStart={}, timeStop={}, message={}",
platformName, platform.name,
satelliteId, satelliteId,
platform.nsiId,
platform.businessKey,
timeStart, timeStart,
timeStop, timeStop,
ex.message ex.message
@@ -2,7 +2,7 @@ package space.nstart.pcp_tgu_service.integration.api
/** Вызывает сервис классификатора и возвращает сырые метаданные платформ. */ /** Вызывает сервис классификатора и возвращает сырые метаданные платформ. */
import space.nstart.pcp_tgu_service.config.ClassifierProperties import space.nstart.pcp_tgu_service.config.ClassifierProperties
import space.nstart.pcp_tgu_service.dto.PlatformsResponseDTO import space.nstart.pcp_tgu_service.dto.NsiPlatformDto
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.WebClient
@@ -19,13 +19,13 @@ class PlatformsClassifierClient(
.build() .build()
/** /**
* Загружает полный ответ классификатора платформ. * Загружает список платформ из классификатора НСИ.
*/ */
/** Загружает полный ответ классификатора вместе с содержимым платформ. */ fun fetchPlatforms(): List<NsiPlatformDto> =
fun fetchPlatforms(): PlatformsResponseDTO =
webClient.get() webClient.get()
.retrieve() .retrieve()
.bodyToMono(PlatformsResponseDTO::class.java) .bodyToMono(Array<NsiPlatformDto>::class.java)
.map { platforms -> platforms.toList() }
.block() .block()
?: throw IllegalStateException("Classifier response body is empty: ${classifierProperties.platformsUrl}") ?: throw IllegalStateException("Classifier response body is empty: ${classifierProperties.platformsUrl}")
} }
@@ -1,29 +1,56 @@
package space.nstart.pcp_tgu_service.service package space.nstart.pcp_tgu_service.service
/** Загружает метаданные платформ КА и применяет простую in-memory фильтрацию. */ /** Загружает метаданные платформ КА и применяет простую in-memory фильтрацию. */
import space.nstart.pcp_tgu_service.dto.ContentItemDTO import space.nstart.pcp_tgu_service.config.ClassifierProperties
import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem
import space.nstart.pcp_tgu_service.dto.NsiPlatformDto
import space.nstart.pcp_tgu_service.integration.api.PlatformsClassifierClient import space.nstart.pcp_tgu_service.integration.api.PlatformsClassifierClient
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.Instant
@Service @Service
class PlatformService( class PlatformService(
private val platformsClassifierClient: PlatformsClassifierClient private val platformsClassifierClient: PlatformsClassifierClient,
private val classifierProperties: ClassifierProperties
) { ) {
/** Возвращает платформы из классификатора с необязательной фильтрацией по коду типа и статусу. */ private val log = LoggerFactory.getLogger(javaClass)
fun loadPlatforms(
platformTypeCode: String? = null,
status: String? = null
): List<ContentItemDTO> {
val platforms = platformsClassifierClient.fetchPlatforms().content.orEmpty() /** Возвращает платформы из классификатора с необязательной фильтрацией по статусу НСИ. */
fun loadPlatforms(status: String? = null): List<PlatformCatalogItem> {
val platforms = platformsClassifierClient.fetchPlatforms()
.mapNotNull { platform -> platform.toCatalogItem() }
return platforms.filter { platform -> return platforms.filter { platform -> status.isNullOrBlank() || platform.status == status }
val matchesPlatformTypeCode = platformTypeCode.isNullOrBlank() ||
platform.data?.platformTypeCode == platformTypeCode
val matchesStatus = status.isNullOrBlank() ||
platform.status == status
matchesPlatformTypeCode && matchesStatus
} }
/** Возвращает платформы, с которыми TGU разрешено работать сейчас. */
fun loadAllowedPlatforms(now: Instant = Instant.now()): List<PlatformCatalogItem> =
loadPlatforms().filter { platform ->
platform.noradId != null &&
platform.status in classifierProperties.allowedPlatformStatuses &&
platform.validFrom != null &&
platform.validTo != null &&
!platform.validFrom.isAfter(now) &&
now.isBefore(platform.validTo)
}
private fun NsiPlatformDto.toCatalogItem(): PlatformCatalogItem? {
val nsiId = id
if (nsiId == null) {
log.warn("Skipping NSI platform without id. businessKey={}, noradId={}", businessKey, data?.noradId)
return null
}
return PlatformCatalogItem(
nsiId = nsiId,
businessKey = businessKey,
name = data?.name,
status = data?.status,
noradId = data?.noradId,
mission = data?.mission,
validFrom = validFrom,
validTo = validTo
)
} }
} }
@@ -151,8 +151,8 @@ class SatelliteMissionProcessService(
/** Проверяет, что КА присутствует во внешнем классификаторе платформ. */ /** Проверяет, что КА присутствует во внешнем классификаторе платформ. */
private fun ensurePlatformExists(noradId: Long) { private fun ensurePlatformExists(noradId: Long) {
/** Признак того, что платформа с указанным NORAD-идентификатором существует во внешнем классификаторе. */ /** Признак того, что платформа с указанным NORAD-идентификатором существует во внешнем классификаторе. */
val platformExists: Boolean = platformService.loadPlatforms() val platformExists: Boolean = platformService.loadAllowedPlatforms()
.any { platform -> platform.data?.noradId == noradId } .any { platform -> platform.noradId == noradId }
if (!platformExists) { if (!platformExists) {
throw ResponseStatusException( throw ResponseStatusException(
@@ -0,0 +1,110 @@
package space.nstart.pcp_tgu_service.integration.api
import com.sun.net.httpserver.HttpServer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import space.nstart.pcp_tgu_service.config.ClassifierProperties
import space.nstart.pcp_tgu_service.config.ExternalApiProperties
import space.nstart.pcp_tgu_service.config.PlanningProperties
import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem
import space.nstart.pcp_tgu_service.service.PlatformService
import java.net.InetSocketAddress
import java.time.Instant
import java.util.Collections
class ExternalPointsClientTest {
@Test
fun `fetchAllSpacecraftPoints uses allowed platforms and keeps noradId as spacecraftId`() {
val requestedPaths = Collections.synchronizedList(mutableListOf<String>())
withServer(requestedPaths) { baseUrl ->
val client = ExternalPointsClient(
externalApiProperties = ExternalApiProperties(baseUrl = baseUrl),
platformService = FixedPlatformService(
listOf(
platform(nsiId = "allowed-1", noradId = 25544L),
platform(nsiId = "allowed-2", noradId = 41386L)
)
),
planningProperties = planningProperties()
)
val points = client.fetchAllSpacecraftPoints()
assertEquals(1, points.size)
assertEquals("25544", points.single().spacecraftId)
assertEquals(listOf("7"), points.single().points.map { it.pointId })
assertEquals(1, points.single().points.single().observationWindows.size)
assertEquals(
listOf(
"/api/satellites/25544/rva",
"/api/satellites/41386/rva"
),
requestedPaths.map { path -> path.substringBefore("?") }
)
}
}
private class FixedPlatformService(
private val allowedPlatforms: List<PlatformCatalogItem>
) : PlatformService(
mock(PlatformsClassifierClient::class.java),
ClassifierProperties(
platformsUrl = "http://localhost",
allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY")
)
) {
override fun loadPlatforms(status: String?): List<PlatformCatalogItem> =
error("ExternalPointsClient must use loadAllowedPlatforms")
override fun loadAllowedPlatforms(now: Instant): List<PlatformCatalogItem> = allowedPlatforms
}
private fun withServer(requestedPaths: MutableList<String>, block: (String) -> Unit) {
val server = HttpServer.create(InetSocketAddress(0), 0)
server.createContext("/") { exchange ->
requestedPaths.add(exchange.requestURI.toString())
val responseBody = when (exchange.requestURI.path) {
"/api/satellites/25544/rva" -> """[{"noradId":25544,"stationId":7,"revolution":1,"onStart":{"time":"2026-05-26T10:00:00Z","elevation":1.0},"onMaximum":{"time":"2026-05-26T10:05:00Z","elevation":45.0},"onStop":{"time":"2026-05-26T10:10:00Z","elevation":1.0}}]"""
else -> """{"error":"failed"}"""
}
val status = if (exchange.requestURI.path == "/api/satellites/25544/rva") 200 else 500
val responseBytes = responseBody.toByteArray()
exchange.responseHeaders.add("Content-Type", "application/json")
exchange.sendResponseHeaders(status, responseBytes.size.toLong())
exchange.responseBody.use { body -> body.write(responseBytes) }
}
server.start()
try {
block("http://localhost:${server.address.port}")
} finally {
server.stop(0)
}
}
private fun platform(nsiId: String, noradId: Long): PlatformCatalogItem =
PlatformCatalogItem(
nsiId = nsiId,
businessKey = "business-$nsiId",
name = "Satellite $nsiId",
status = "OPERATIONAL",
noradId = noradId,
mission = "MISSION",
validFrom = Instant.parse("2026-01-01T00:00:00Z"),
validTo = Instant.parse("9999-12-31T23:59:59Z")
)
private fun planningProperties(): PlanningProperties =
PlanningProperties(
notificationBeforeStartMinutes = 30,
targetPlanDurationMinutes = 24 * 60L,
targetPlanDurationDeltaMinutes = 60L,
externalUpdateEnabled = false,
externalPointsLookaheadDays = 3,
inMemoryHistoryDays = 14,
externalUpdateFixedDelayMs = 0,
notificationCheckFixedDelayMs = 30_000
)
}
@@ -0,0 +1,89 @@
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.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import space.nstart.pcp_tgu_service.config.ClassifierProperties
import space.nstart.pcp_tgu_service.dto.NsiPlatformDto
import java.net.InetSocketAddress
import java.nio.file.Files
import java.nio.file.Path
class PlatformsClassifierClientTest {
@Test
fun `sample platforms json is deserialized as root array and ignores unknown fields`() {
val objectMapper = jacksonObjectMapper().findAndRegisterModules()
val json = Files.readString(Path.of("platforms.json"))
val platforms = objectMapper.readValue(json, Array<NsiPlatformDto>::class.java).toList()
val operationalPlatform = platforms.first { platform -> platform.data?.status == "OPERATIONAL" }
assertNotNull(operationalPlatform.id)
assertNotNull(operationalPlatform.businessKey)
assertNotNull(operationalPlatform.data?.name)
assertEquals("OPERATIONAL", operationalPlatform.data?.status)
assertNotNull(operationalPlatform.data?.noradId)
assertNotNull(operationalPlatform.validFrom)
assertNotNull(operationalPlatform.validTo)
}
@Test
fun `fetchPlatforms reads root array response`() {
withServer("""[{"id":"nsi-id","businessKey":"business-key","data":{"name":"Satellite","status":"OPERATIONAL","norad_id":25544,"mission":"MISSION","orbit":{"type":"SSO"}},"validFrom":"2026-01-01T00:00:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US"}}]""") { baseUrl ->
val client = PlatformsClassifierClient(
ClassifierProperties(
platformsUrl = baseUrl,
allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY")
)
)
val platforms = client.fetchPlatforms()
assertEquals(1, platforms.size)
assertEquals("nsi-id", platforms.single().id)
assertEquals("business-key", platforms.single().businessKey)
assertEquals("Satellite", platforms.single().data?.name)
assertEquals("OPERATIONAL", platforms.single().data?.status)
assertEquals(25544L, platforms.single().data?.noradId)
assertEquals("MISSION", platforms.single().data?.mission)
}
}
@Test
fun `fetchPlatforms throws clear error on empty body`() {
withServer("") { baseUrl ->
val client = PlatformsClassifierClient(
ClassifierProperties(
platformsUrl = baseUrl,
allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY")
)
)
val exception = assertThrows(IllegalStateException::class.java) {
client.fetchPlatforms()
}
assertEquals("Classifier response body is empty: $baseUrl", exception.message)
}
}
private fun withServer(responseBody: String, block: (String) -> Unit) {
val server = HttpServer.create(InetSocketAddress(0), 0)
server.createContext("/") { exchange ->
val responseBytes = responseBody.toByteArray()
exchange.responseHeaders.add("Content-Type", "application/json")
exchange.sendResponseHeaders(200, responseBytes.size.toLong())
exchange.responseBody.use { body -> body.write(responseBytes) }
}
server.start()
try {
block("http://localhost:${server.address.port}")
} finally {
server.stop(0)
}
}
}
@@ -0,0 +1,94 @@
package space.nstart.pcp_tgu_service.service
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.`when`
import space.nstart.pcp_tgu_service.config.ClassifierProperties
import space.nstart.pcp_tgu_service.dto.NsiPlatformDataDto
import space.nstart.pcp_tgu_service.dto.NsiPlatformDto
import space.nstart.pcp_tgu_service.integration.api.PlatformsClassifierClient
import java.time.Instant
class PlatformServiceTest {
@Test
fun `loadPlatforms maps NSI DTO and filters by status`() {
val client = mock(PlatformsClassifierClient::class.java)
`when`(client.fetchPlatforms()).thenReturn(
listOf(
platformDto(id = "operational-id", status = "OPERATIONAL", noradId = 25544L),
platformDto(id = "standby-id", status = "STANDBY", noradId = 41386L),
platformDto(id = "lost-id", status = "LOST", noradId = 39186L),
platformDto(id = null, status = "OPERATIONAL", noradId = 2L)
)
)
val service = service(client)
val allPlatforms = service.loadPlatforms()
val platforms = service.loadPlatforms(status = "STANDBY")
assertEquals(listOf("operational-id", "standby-id", "lost-id"), allPlatforms.map { it.nsiId })
assertEquals(1, platforms.size)
assertEquals("standby-id", platforms.single().nsiId)
assertEquals("business-standby-id", platforms.single().businessKey)
assertEquals("Satellite standby-id", platforms.single().name)
assertEquals("STANDBY", platforms.single().status)
assertEquals(41386L, platforms.single().noradId)
assertEquals("MISSION", platforms.single().mission)
assertEquals(Instant.parse("2026-01-01T00:00:00Z"), platforms.single().validFrom)
assertEquals(Instant.parse("2026-12-31T00:00:00Z"), platforms.single().validTo)
}
@Test
fun `loadAllowedPlatforms uses configured statuses noradId and validity interval`() {
val now = Instant.parse("2026-05-26T12:00:00Z")
val client = mock(PlatformsClassifierClient::class.java)
`when`(client.fetchPlatforms()).thenReturn(
listOf(
platformDto(id = "operational-id", status = "OPERATIONAL", noradId = 1L),
platformDto(id = "standby-id", status = "STANDBY", noradId = 2L),
platformDto(id = "lost-id", status = "LOST", noradId = 3L),
platformDto(id = "without-norad-id", status = "OPERATIONAL", noradId = null),
platformDto(id = "future-id", status = "OPERATIONAL", noradId = 4L, validFrom = "2026-06-01T00:00:00Z"),
platformDto(id = "expired-id", status = "OPERATIONAL", noradId = 5L, validTo = "2026-05-26T12:00:00Z"),
platformDto(id = "without-valid-from", status = "OPERATIONAL", noradId = 6L, validFrom = null),
platformDto(id = "without-valid-to", status = "OPERATIONAL", noradId = 7L, validTo = null)
)
)
val service = service(client)
val allowedPlatforms = service.loadAllowedPlatforms(now)
assertEquals(listOf("operational-id", "standby-id"), allowedPlatforms.map { it.nsiId })
}
private fun service(client: PlatformsClassifierClient): PlatformService =
PlatformService(
platformsClassifierClient = client,
classifierProperties = ClassifierProperties(
platformsUrl = "http://localhost",
allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY")
)
)
private fun platformDto(
id: String?,
status: String,
noradId: Long?,
validFrom: String? = "2026-01-01T00:00:00Z",
validTo: String? = "2026-12-31T00:00:00Z"
): NsiPlatformDto =
NsiPlatformDto(
id = id,
businessKey = id?.let { "business-$it" },
data = NsiPlatformDataDto(
name = id?.let { "Satellite $it" },
status = status,
noradId = noradId,
mission = "MISSION"
),
validFrom = validFrom?.let(Instant::parse),
validTo = validTo?.let(Instant::parse)
)
}
@@ -13,11 +13,11 @@ import org.mockito.Mockito.verify
import org.mockito.Mockito.`when` import org.mockito.Mockito.`when`
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import space.nstart.pcp_tgu_service.config.ClassifierProperties
import space.nstart.pcp_tgu_service.config.PlanningProperties import space.nstart.pcp_tgu_service.config.PlanningProperties
import space.nstart.pcp_tgu_service.domain.Plan import space.nstart.pcp_tgu_service.domain.Plan
import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem
import space.nstart.pcp_tgu_service.domain.SpacecraftPoints import space.nstart.pcp_tgu_service.domain.SpacecraftPoints
import space.nstart.pcp_tgu_service.dto.ContentDataDTO
import space.nstart.pcp_tgu_service.dto.ContentItemDTO
import space.nstart.pcp_tgu_service.dto.CreateSatelliteMissionVariables import space.nstart.pcp_tgu_service.dto.CreateSatelliteMissionVariables
import space.nstart.pcp_tgu_service.dto.PointMessage import space.nstart.pcp_tgu_service.dto.PointMessage
import space.nstart.pcp_tgu_service.dto.SpacecraftPointsMessage import space.nstart.pcp_tgu_service.dto.SpacecraftPointsMessage
@@ -28,6 +28,7 @@ import space.nstart.pcp_tgu_service.integration.api.PlatformsClassifierClient
import space.nstart.pcp_tgu_service.repository.PlanRepository import space.nstart.pcp_tgu_service.repository.PlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPointsRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPointsRepository
import space.nstart.pcp_tgu_service.repository.TrackedPlanJpaRepository import space.nstart.pcp_tgu_service.repository.TrackedPlanJpaRepository
import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
@@ -199,10 +200,36 @@ class SatelliteMissionProcessServiceTest {
verify(planQueryService).reuseExistingPlan(waitingPlan) verify(planQueryService).reuseExistingPlan(waitingPlan)
} }
@Test
fun `startCreateSatelliteMissionManually returns not found when platform is not allowed`() {
val service = SatelliteMissionProcessService(
camundaClient = mock(CamundaClient::class.java),
platformService = FixedPlatformService(emptyList()),
spacecraftPointsService = FixedSpacecraftPointsService(
SpacecraftPointsMessage("25544", emptyList())
),
planCalculationService = mock(PlanCalculationService::class.java),
planQueryService = mock(PlanQueryService::class.java),
trackedPlanService = FixedTrackedPlanService()
)
val exception = assertThrows(ResponseStatusException::class.java) {
service.startCreateSatelliteMissionManually(25544L)
}
assertEquals(HttpStatus.NOT_FOUND, exception.statusCode)
}
private class FixedPlatformService( private class FixedPlatformService(
private val platforms: List<ContentItemDTO> private val platforms: List<PlatformCatalogItem>
) : PlatformService(mock(PlatformsClassifierClient::class.java)) { ) : PlatformService(
override fun loadPlatforms(platformTypeCode: String?, status: String?): List<ContentItemDTO> = platforms mock(PlatformsClassifierClient::class.java),
ClassifierProperties(
platformsUrl = "http://localhost",
allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY")
)
) {
override fun loadAllowedPlatforms(now: Instant): List<PlatformCatalogItem> = platforms
} }
private class FixedSpacecraftPointsService( private class FixedSpacecraftPointsService(
@@ -284,21 +311,16 @@ class SatelliteMissionProcessServiceTest {
} }
} }
private fun platform(noradId: Long): ContentItemDTO = private fun platform(noradId: Long): PlatformCatalogItem =
ContentItemDTO( PlatformCatalogItem(
uuid = UUID.randomUUID().toString(), nsiId = UUID.randomUUID().toString(),
parentItemUuid = null, businessKey = "business-key-$noradId",
status = "ACTIVE",
initiation = null,
expiration = null,
data = ContentDataDTO(
platformTypeCode = null,
noradId = noradId,
umn = null,
name = "satellite", name = "satellite",
description = null, status = "OPERATIONAL",
platformTypeName = null noradId = noradId,
) mission = "MISSION",
validFrom = Instant.parse("2026-01-01T00:00:00Z"),
validTo = Instant.parse("9999-12-31T23:59:59Z")
) )
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")