pcp: единообразие времени (UTC) по группе сервисов

Фронт (pcp-tgu-ops-ui):
- тест-страж utcTime.guard.test.ts: запрещает Date.parse / new Date(string) /
  toLocale* без timeZone вне utils/utcTime; тестовые фикстуры переведены на parseUtc.

Бэкенд:
- UTC-now: LocalDateTime.now() -> LocalDateTime.now(ZoneOffset.UTC) по всем сервисам
  (entity-дефолты, воркеры, use case'ы) и в тестовых фикстурах.
- Конвертации к UTC (баги на не-UTC JVM, каждая с регресс-тестом в не-UTC поясе):
  - tgu: VisibilityPayloadParser, SyncSpacecraftFromNsiUseCase (Instant -> UTC LocalDateTime);
  - mission-planing: MissionPlaningService (OffsetDateTime -> UTC через toUtcLocalDateTime);
  - dynamic-plan: RequestServiceClient (OffsetDateTime -> UTC);
  - slots: SlotRepositoryImpl (getObject(LocalDateTime) вместо getTimestamp().toLocalDateTime()).
- Контрактные сериализационные тесты формата на проводе (naive без зоны / Z):
  PlanResponse (tgu), RequestResponseDto (request, эталон), TguPlanning DTO (ui).
- KDoc-инвариант «=UTC» на UI-facing DTO (tgu PlanResponse, ui-service DTO вкладки ТГУ).

Документация: docs/standards/DATETIME_UTC.md (соглашение, таблица сервисов, под-задачи на миграцию к Z),
docs/standards/CHECKLIST.md (пункт про UTC на API).
This commit is contained in:
Дмитрий Соловьев
2026-06-02 21:51:38 +03:00
parent ac2220d063
commit 260583b134
84 changed files with 760 additions and 166 deletions
@@ -0,0 +1,47 @@
package org.nstart.dep265.requestservice.dto
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
/**
* Контракт формата времени на проводе для [RequestResponseDto] — эталон группы: `OffsetDateTime`
* сериализуется с явной зоной `Z` (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф
* (смену типа поля на naive `LocalDateTime` или Jackson-конфига).
*
* Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS).
*/
class RequestResponseDtoContractTest {
private val mapper = jacksonObjectMapper().findAndRegisterModules()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
@Test
fun `serializes request times as ISO with explicit Z zone`() {
val dto = RequestResponseDto(
id = UUID.fromString("00000000-0000-0000-0000-000000000001"),
name = "request",
status = RequestStatusDto.ACTIVE,
surveyType = SurveyTypeDto.OPTICS,
geometry = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
importance = 1.0,
beginDateTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC),
endDateTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC),
highPriorityTransmit = false,
coverage = CoverageStateDto(currentPercent = 0.0),
createdAt = OffsetDateTime.of(2026, 5, 29, 0, 0, 0, 0, ZoneOffset.UTC),
updatedAt = OffsetDateTime.of(2026, 5, 29, 0, 0, 0, 0, ZoneOffset.UTC)
)
val node = mapper.readTree(mapper.writeValueAsString(dto))
assertEquals("2026-05-29T01:00:00Z", node.get("beginDateTime").asText())
assertEquals("2026-05-29T02:00:00Z", node.get("endDateTime").asText())
assertTrue(node.get("createdAt").asText().endsWith("Z"), "ожидалась явная зона Z")
}
}
@@ -13,6 +13,7 @@ import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@@ -112,14 +113,14 @@ class RequestGridProjectionServiceTest {
geometryArea = 1.0,
remainingArea = 1.0,
importance = importance,
beginDateTime = LocalDateTime.now(),
endDateTime = LocalDateTime.now().plusDays(1),
beginDateTime = LocalDateTime.now(ZoneOffset.UTC),
endDateTime = LocalDateTime.now(ZoneOffset.UTC).plusDays(1),
highPriorityTransmit = false,
surveyType = RequestSurveyType.OPTICS,
status = RequestStatus.ACTIVE,
matchCount = 0,
createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now(),
createdAt = LocalDateTime.now(ZoneOffset.UTC),
updatedAt = LocalDateTime.now(ZoneOffset.UTC),
)
)
@@ -29,6 +29,7 @@ import org.springframework.transaction.support.SimpleTransactionStatus
import org.springframework.transaction.support.TransactionCallback
import org.springframework.transaction.support.TransactionOperations
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.Optional
import java.util.UUID
@@ -47,7 +48,7 @@ class RouteMatchingServiceTest {
geometryService = geometryService,
)
val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0)
val intervalEnd = intervalBegin.plusHours(1)
val request = request(
beginDateTime = intervalBegin,
@@ -96,7 +97,7 @@ class RouteMatchingServiceTest {
geometryService = geometryService,
)
val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0)
val intervalEnd = intervalBegin.plusHours(1)
val originalRemainingGeometry = "POLYGON((1 1, 2 1, 2 2, 1 2, 1 1))"
val request = request(
@@ -146,7 +147,7 @@ class RouteMatchingServiceTest {
geometryService = geometryService,
)
val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0)
val request = request(
beginDateTime = intervalBegin,
endDateTime = intervalBegin.plusHours(1),
@@ -182,7 +183,7 @@ class RouteMatchingServiceTest {
geometryService = geometryService,
)
val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0)
val request = request(
beginDateTime = intervalBegin,
endDateTime = intervalBegin.plusHours(1),
@@ -230,7 +231,7 @@ class RouteMatchingServiceTest {
geometryService = geometryService,
)
val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0)
val request = request(
beginDateTime = intervalBegin,
endDateTime = intervalBegin.plusHours(1),
@@ -268,7 +269,7 @@ class RouteMatchingServiceTest {
geometryService = geometryService,
)
val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0)
val request = request(
beginDateTime = intervalBegin,
endDateTime = intervalBegin.plusHours(1),