Перевод 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
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:
polling:
+4 -1
View File
@@ -38,7 +38,10 @@ topics:
spacecraft-points-update: pcp.tgu.kpp-update.v1
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:
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(
/** URL классификатора космических аппаратов. */
val platformsUrl: String
val platformsUrl: String,
/** Статусы КА из НСИ, с которыми сервису разрешено работать. */
val allowedPlatformStatuses: Set<String>
)
@@ -1,7 +1,8 @@
package space.nstart.pcp_tgu_service.controller
/** Содержит методы 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 io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
@@ -25,14 +26,24 @@ class PlatformController(
/**
* Возвращает список космических аппаратов из внешнего классификатора.
*/
/** Возвращает платформы с необязательной фильтрацией по типу и статусу. */
@GetMapping
@Operation(summary = "Получить список космических аппаратов")
fun getPlatforms(
@RequestParam(required = false) platformTypeCode: String?,
@RequestParam(required = false) status: String?
): Mono<List<ContentItemDTO>> =
): Mono<List<PlatformResponse>> =
Mono.fromCallable {
platformService.loadPlatforms(platformTypeCode, status)
platformService.loadPlatforms(status).map { platform -> platform.toResponse() }
}.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.JsonProperty
import com.fasterxml.jackson.databind.ObjectMapper
import java.time.Instant
/**
* Корневой DTO JSON-ответа.
* Соответствует всему объекту JSON.
*/
/** Платформа из root-array ответа НСИ. */
@JsonIgnoreProperties(ignoreUnknown = true)
data class PlatformsResponseDTO(
/**
* UUID справочника.
*/
val uuid: String?,
/**
* 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 class NsiPlatformDto(
val id: String?,
val businessKey: String?,
val data: NsiPlatformDataDto?,
val validFrom: Instant?,
val validTo: Instant?
)
/**
* DTO описания одного поля.
*/
/** Согласованные поля объекта data платформы НСИ. */
@JsonIgnoreProperties(ignoreUnknown = true)
data class FieldDTO(
/**
* UUID поля.
*/
val uuid: String?,
/**
* API имя поля.
*/
val apiName: String?,
/**
* GUI имя поля.
*/
val guiName: String?,
/**
* Статус поля.
*/
data class NsiPlatformDataDto(
val name: 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")
val noradId: Long?,
val mission: String?
)
/**
* Уникальный номер изделия (УМН).
*/
val umn: String?,
/**
* Название аппарата.
*/
data class PlatformResponse(
val id: String,
val businessKey: String?,
val name: String?,
/**
* Описание аппарата.
*/
val description: String?,
/**
* Название типа платформы.
*/
@param:JsonProperty("platform_type_name")
val platformTypeName: String?
val status: String?,
val noradId: Long?,
val mission: String?,
val validFrom: Instant?,
val validTo: Instant?
)
/**
* 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.domain.InsertionPoint
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.dto.ContentItemDTO
import space.nstart.pcp_tgu_service.dto.ExternalRvaItemDto
import space.nstart.pcp_tgu_service.service.PlatformService
import org.slf4j.LoggerFactory
@@ -39,16 +39,15 @@ class ExternalPointsClient(
/**
* Загружает окна видимости по всем КА из классификатора,
* у которых заполнен NORAD ID.
* которые разрешены для работы TGU.
*/
/** Загружает окна видимости для всех КА с заполненным NORAD-идентификатором в классификаторе. */
fun fetchAllSpacecraftPoints(): List<SpacecraftPoints> {
val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES)
val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays)
return platformService.loadPlatforms()
return platformService.loadAllowedPlatforms()
.mapNotNull { platform ->
val noradId = platform.data?.noradId ?: return@mapNotNull null
val noradId = requireNotNull(platform.noradId)
fetchSpacecraftPointsSafely(
platform = platform,
satelliteId = noradId,
@@ -60,7 +59,7 @@ class ExternalPointsClient(
/** Оборачивает загрузку по одному КА и подавляет транспортные ошибки только для него. */
private fun fetchSpacecraftPointsSafely(
platform: ContentItemDTO,
platform: PlatformCatalogItem,
satelliteId: Long,
timeStart: LocalDateTime,
timeStop: LocalDateTime
@@ -138,19 +137,19 @@ class ExternalPointsClient(
}
private fun logExternalApiError(
platform: ContentItemDTO,
platform: PlatformCatalogItem,
satelliteId: Long,
timeStart: LocalDateTime,
timeStop: LocalDateTime,
ex: WebClientException
) {
val platformName = platform.data?.name
if (ex is WebClientResponseException) {
log.warn(
"Skipping spacecraft due to external API response error. name={}, noradId={}, timeStart={}, timeStop={}, status={}, responseBody={}",
platformName,
"Skipping spacecraft due to external API response error. name={}, noradId={}, nsiId={}, businessKey={}, timeStart={}, timeStop={}, status={}, responseBody={}",
platform.name,
satelliteId,
platform.nsiId,
platform.businessKey,
timeStart,
timeStop,
ex.statusCode.value(),
@@ -160,9 +159,11 @@ class ExternalPointsClient(
}
log.warn(
"Skipping spacecraft due to external API transport error. name={}, noradId={}, timeStart={}, timeStop={}, message={}",
platformName,
"Skipping spacecraft due to external API transport error. name={}, noradId={}, nsiId={}, businessKey={}, timeStart={}, timeStop={}, message={}",
platform.name,
satelliteId,
platform.nsiId,
platform.businessKey,
timeStart,
timeStop,
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.dto.PlatformsResponseDTO
import space.nstart.pcp_tgu_service.dto.NsiPlatformDto
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.WebClient
@@ -19,13 +19,13 @@ class PlatformsClassifierClient(
.build()
/**
* Загружает полный ответ классификатора платформ.
* Загружает список платформ из классификатора НСИ.
*/
/** Загружает полный ответ классификатора вместе с содержимым платформ. */
fun fetchPlatforms(): PlatformsResponseDTO =
fun fetchPlatforms(): List<NsiPlatformDto> =
webClient.get()
.retrieve()
.bodyToMono(PlatformsResponseDTO::class.java)
.bodyToMono(Array<NsiPlatformDto>::class.java)
.map { platforms -> platforms.toList() }
.block()
?: throw IllegalStateException("Classifier response body is empty: ${classifierProperties.platformsUrl}")
}
@@ -1,29 +1,56 @@
package space.nstart.pcp_tgu_service.service
/** Загружает метаданные платформ КА и применяет простую 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 org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.Instant
@Service
class PlatformService(
private val platformsClassifierClient: PlatformsClassifierClient
private val platformsClassifierClient: PlatformsClassifierClient,
private val classifierProperties: ClassifierProperties
) {
/** Возвращает платформы из классификатора с необязательной фильтрацией по коду типа и статусу. */
fun loadPlatforms(
platformTypeCode: String? = null,
status: String? = null
): List<ContentItemDTO> {
private val log = LoggerFactory.getLogger(javaClass)
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 ->
val matchesPlatformTypeCode = platformTypeCode.isNullOrBlank() ||
platform.data?.platformTypeCode == platformTypeCode
val matchesStatus = status.isNullOrBlank() ||
platform.status == status
matchesPlatformTypeCode && matchesStatus
return platforms.filter { platform -> status.isNullOrBlank() || platform.status == status }
}
/** Возвращает платформы, с которыми 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) {
/** Признак того, что платформа с указанным NORAD-идентификатором существует во внешнем классификаторе. */
val platformExists: Boolean = platformService.loadPlatforms()
.any { platform -> platform.data?.noradId == noradId }
val platformExists: Boolean = platformService.loadAllowedPlatforms()
.any { platform -> platform.noradId == noradId }
if (!platformExists) {
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.springframework.http.HttpStatus
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.domain.Plan
import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem
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.PointMessage
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.SpacecraftPointsRepository
import space.nstart.pcp_tgu_service.repository.TrackedPlanJpaRepository
import java.time.Instant
import java.time.LocalDateTime
import java.util.UUID
@@ -199,10 +200,36 @@ class SatelliteMissionProcessServiceTest {
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 val platforms: List<ContentItemDTO>
) : PlatformService(mock(PlatformsClassifierClient::class.java)) {
override fun loadPlatforms(platformTypeCode: String?, status: String?): List<ContentItemDTO> = platforms
private val platforms: List<PlatformCatalogItem>
) : PlatformService(
mock(PlatformsClassifierClient::class.java),
ClassifierProperties(
platformsUrl = "http://localhost",
allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY")
)
) {
override fun loadAllowedPlatforms(now: Instant): List<PlatformCatalogItem> = platforms
}
private class FixedSpacecraftPointsService(
@@ -284,21 +311,16 @@ class SatelliteMissionProcessServiceTest {
}
}
private fun platform(noradId: Long): ContentItemDTO =
ContentItemDTO(
uuid = UUID.randomUUID().toString(),
parentItemUuid = null,
status = "ACTIVE",
initiation = null,
expiration = null,
data = ContentDataDTO(
platformTypeCode = null,
noradId = noradId,
umn = null,
private fun platform(noradId: Long): PlatformCatalogItem =
PlatformCatalogItem(
nsiId = UUID.randomUUID().toString(),
businessKey = "business-key-$noradId",
name = "satellite",
description = null,
platformTypeName = null
)
status = "OPERATIONAL",
noradId = noradId,
mission = "MISSION",
validFrom = Instant.parse("2026-01-01T00:00:00Z"),
validTo = Instant.parse("9999-12-31T23:59:59Z")
)
@Suppress("UNCHECKED_CAST")