diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsService.java index d6b9ae1..45df50f 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsService.java @@ -88,7 +88,11 @@ public class SnapshotsService { boolean currentMarked = false; DateTimeFormatter fmt = "hour".equals(gran) ? RU_HOUR : RU_DAY; for (Object[] row : rows) { - OffsetDateTime bucket = ((java.sql.Timestamp) row[0]).toInstant().atOffset(now.getOffset()); + // pgJDBC 42.7+ возвращает timestamptz как java.time.Instant вместо + // java.sql.Timestamp (как было в 42.6). Native query тип определяется + // в runtime — поддерживаем оба + OffsetDateTime для completeness. + // Без этой branch'и был ClassCastException 500 на каждом запросе. + OffsetDateTime bucket = toBucketOffsetDateTime(row[0], now); long changedSinceLast = ((Number) row[1]).longValue(); boolean isCurrent = !currentMarked && !bucket.isAfter(now); if (isCurrent) currentMarked = true; @@ -103,6 +107,38 @@ public class SnapshotsService { return new SnapshotsResponse(d.getName(), windowFrom, windowTo, snapshots); } + /** + * Convert native-query Object[] timestamp column → OffsetDateTime. + * + *

Postgres JDBC driver returns timestamptz columns в разных форматах + * в зависимости от версии: + *

+ * + *

Native query через {@code @Query(nativeQuery = true)} возвращает Object[] + * без compile-time типа — поддерживаем все три варианта. Иначе CCE на runtime. + */ + static OffsetDateTime toBucketOffsetDateTime(Object raw, OffsetDateTime now) { + if (raw == null) { + throw new IllegalStateException("bucket timestamp is null in result row"); + } + if (raw instanceof java.sql.Timestamp ts) { + return ts.toInstant().atOffset(now.getOffset()); + } + if (raw instanceof java.time.Instant inst) { + return inst.atOffset(now.getOffset()); + } + if (raw instanceof OffsetDateTime odt) { + return odt.withOffsetSameInstant(now.getOffset()); + } + throw new IllegalStateException( + "Unexpected timestamp type from native query: " + raw.getClass().getName()); + } + // === DTOs === public record Snapshot( diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsServiceTest.java index 3335fdd..95440ca 100644 --- a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsServiceTest.java +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsServiceTest.java @@ -112,6 +112,63 @@ class SnapshotsServiceTest { assertThat(result.snapshots().get(1).changedSinceLast()).isEqualTo(5L); } + /** + * Регрессия prod-bug 2026-05-19: pgJDBC 42.7+ возвращает timestamptz как + * {@link java.time.Instant} вместо {@code java.sql.Timestamp}. Старый код + * жёстко кастил в Timestamp → ClassCastException на каждом запросе. + * + *

Тест проверяет что service корректно мапит Instant в OffsetDateTime. + */ + @Test + void snapshots_acceptsInstantFromPg42_7Driver() { + var d = def("dict", DataScope.PUBLIC); + when(defRepo.findByName("dict")).thenReturn(Optional.of(d)); + java.time.Instant april = OffsetDateTime.parse("2026-04-01T00:00:00Z").toInstant(); + java.time.Instant may = OffsetDateTime.parse("2026-05-11T00:00:00Z").toInstant(); + when(recRepo.findSnapshotBuckets(eq(d.getId()), anyString(), any(), any(), eq("day"))) + .thenReturn(List.of( + new Object[]{may, 5L}, + new Object[]{april, 3L})); + + var result = service.findSnapshots("dict", null, null, "day"); + + assertThat(result.snapshots()).hasSize(2); + assertThat(result.snapshots().get(0).changedSinceLast()).isEqualTo(3L); + assertThat(result.snapshots().get(1).changedSinceLast()).isEqualTo(5L); + } + + /** + * Defensive coverage: если configurable + * {@code pgjdbc.timestamp.return-type=offset-date-time} в будущем включат, + * helper должен handle и OffsetDateTime. (Сейчас не активно, но проверяем + * чтобы не сломаться при future driver tuning.) + */ + @Test + void snapshots_acceptsOffsetDateTime() { + var d = def("dict", DataScope.PUBLIC); + when(defRepo.findByName("dict")).thenReturn(Optional.of(d)); + OffsetDateTime apr = OffsetDateTime.parse("2026-04-01T00:00:00Z"); + when(recRepo.findSnapshotBuckets(any(), anyString(), any(), any(), anyString())) + .thenReturn(List.of(new Object[]{apr, 1L})); + + var result = service.findSnapshots("dict", null, null, "day"); + + assertThat(result.snapshots()).hasSize(1); + assertThat(result.snapshots().get(0).changedSinceLast()).isEqualTo(1L); + } + + @Test + void snapshots_rejectsUnexpectedTimestampType() { + var d = def("dict", DataScope.PUBLIC); + when(defRepo.findByName("dict")).thenReturn(Optional.of(d)); + when(recRepo.findSnapshotBuckets(any(), anyString(), any(), any(), anyString())) + .thenReturn(List.of(new Object[]{"2026-04-01", 1L})); + + assertThatThrownBy(() -> service.findSnapshots("dict", null, null, "day")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Unexpected timestamp type"); + } + @Test void snapshots_currentMarkedOnce_evenWithFutureBuckets() { var d = def("dict", DataScope.PUBLIC);