diff --git a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/AutoDeriveGeometryE2ETest.java b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/AutoDeriveGeometryE2ETest.java new file mode 100644 index 0000000..eb7ffe6 --- /dev/null +++ b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/AutoDeriveGeometryE2ETest.java @@ -0,0 +1,233 @@ +package cloud.nstart.terravault.ordinis.app.e2e; + +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.Point; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.offset; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * E2E: backend auto-derive geometry из data.lat/lon при create. Покрывает + * {@code DictionaryRecordService.resolveGeometry()} — все три ветки (явный + * geometryWkt, auto-derive из lat/lon, alias latitude/longitude и lat/lng). + * + *

Тест отражает Liquibase migration 0015: backfill для legacy записей + * без geometry при наличии lat/lon в data — чтобы AOI spatial filter работал + * на ground_station и similar dictionaries. + * + *

Postgres testcontainer уже включает PostGIS (см. {@link E2ESupport}). + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "ordinis.outbox.enabled=false", + "spring.autoconfigure.exclude=" + + "org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration", + }) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AutoDeriveGeometryE2ETest { + + @DynamicPropertySource + static void props(DynamicPropertyRegistry registry) { + // Postgres-only: outbox disabled → Kafka не нужна, экономим ~5s startup + E2ESupport.registerPostgresOnlyProperties(registry); + } + + @Autowired MockMvc mvc; + @Autowired ObjectMapper om; + @Autowired DictionaryRecordRepository recordRepo; + @Autowired DictionaryDefinitionRepository definitionRepo; + + /** Schema с lat/lon (или alias) числовыми полями. */ + private String setupDict(String latKey, String lonKey) throws Exception { + String dict = "geo_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + JsonNode schema = om.readTree(""" + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "x-id-source": "code", + "required": ["code"], + "properties": { + "code": { "type": "string", "x-unique": true }, + "%s": { "type": "number" }, + "%s": { "type": "number" } + } + } + """.formatted(latKey, lonKey)); + + var body = om.createObjectNode() + .put("name", dict) + .put("scope", "PUBLIC") + .put("schemaVersion", "1.0.0") + .put("bundle", "test"); + body.set("schemaJson", schema); + mvc.perform(post("/api/v1/dictionaries") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(body))) + .andExpect(status().is2xxSuccessful()); + + return dict; + } + + private ObjectNode recordBody(String code, double lat, double lon, String latKey, String lonKey) { + ObjectNode body = om.createObjectNode().put("businessKey", code); + ObjectNode data = om.createObjectNode().put("code", code); + data.put(latKey, lat); + data.put(lonKey, lon); + body.set("data", data); + return body; + } + + @Test + void latLon_autoDerivedToPoint() throws Exception { + String dict = setupDict("lat", "lon"); + String code = "MOSCOW"; + + // Москва: 55.75, 37.62 + mvc.perform(post("/api/v1/dictionaries/{n}/records", dict) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(recordBody(code, 55.75, 37.62, "lat", "lon")))) + .andExpect(status().is2xxSuccessful()); + + Point pt = assertOnePointSaved(dict); + // PostGIS возвращает coords в JTS Point: getX = longitude, getY = latitude + assertThat(pt.getX()).isCloseTo(37.62, offset(0.001)); + assertThat(pt.getY()).isCloseTo(55.75, offset(0.001)); + assertThat(pt.getSRID()).isEqualTo(4326); + } + + @Test + void latitudeLongitude_aliasAutoDerived() throws Exception { + String dict = setupDict("latitude", "longitude"); + String code = "SPB"; + + // СПб: 59.93, 30.31 + mvc.perform(post("/api/v1/dictionaries/{n}/records", dict) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes( + recordBody(code, 59.93, 30.31, "latitude", "longitude")))) + .andExpect(status().is2xxSuccessful()); + + Point pt = assertOnePointSaved(dict); + assertThat(pt.getX()).isCloseTo(30.31, offset(0.001)); + assertThat(pt.getY()).isCloseTo(59.93, offset(0.001)); + } + + @Test + void latLng_aliasAutoDerived() throws Exception { + String dict = setupDict("lat", "lng"); + String code = "TOKYO"; + + // Токио: 35.68, 139.65 + mvc.perform(post("/api/v1/dictionaries/{n}/records", dict) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(recordBody(code, 35.68, 139.65, "lat", "lng")))) + .andExpect(status().is2xxSuccessful()); + + Point pt = assertOnePointSaved(dict); + assertThat(pt.getX()).isCloseTo(139.65, offset(0.001)); + assertThat(pt.getY()).isCloseTo(35.68, offset(0.001)); + } + + @Test + void explicitGeometryWkt_takesPriorityOverData() throws Exception { + String dict = setupDict("lat", "lon"); + String code = "EXPLICIT"; + + // data говорит "Москва", но geometryWkt явно указан как "СПб". + // Backend WKTReader (JTS) не принимает SRID=4326; prefix — SRID хардкодится + // на 4326 в коде (parseWkt). WKT без prefix'а: POINT(lon lat). + ObjectNode body = recordBody(code, 55.75, 37.62, "lat", "lon"); + body.put("geometryWkt", "POINT(30.31 59.93)"); + + mvc.perform(post("/api/v1/dictionaries/{n}/records", dict) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(body))) + .andExpect(status().is2xxSuccessful()); + + Point pt = assertOnePointSaved(dict); + // Должен быть СПб (explicit), а не Москва (data) + assertThat(pt.getX()).isCloseTo(30.31, offset(0.001)); + assertThat(pt.getY()).isCloseTo(59.93, offset(0.001)); + } + + @Test + void outOfRangeLatLon_silentlySkipped() throws Exception { + String dict = setupDict("lat", "lon"); + + // lat=200 — out of [-90,90] range. Backend silent skip → geometry null. + mvc.perform(post("/api/v1/dictionaries/{n}/records", dict) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(recordBody("BAD_LAT", 200.0, 50.0, "lat", "lon")))) + .andExpect(status().is2xxSuccessful()); + + // lon=300 — out of [-180,180] range + mvc.perform(post("/api/v1/dictionaries/{n}/records", dict) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(recordBody("BAD_LON", 50.0, 300.0, "lat", "lon")))) + .andExpect(status().is2xxSuccessful()); + + List records = recordsOfDict(dict); + assertThat(records).hasSize(2); + assertThat(records).allMatch(r -> r.getGeometry() == null, + "записи с out-of-range coords должны иметь geometry == null"); + } + + @Test + void noLatLon_geometryRemainsNull() throws Exception { + String dict = setupDict("lat", "lon"); + // Запись без lat/lon в data — geometry должна быть null + ObjectNode body = om.createObjectNode().put("businessKey", "NO_GEO"); + body.set("data", om.createObjectNode().put("code", "NO_GEO")); + mvc.perform(post("/api/v1/dictionaries/{n}/records", dict) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(body))) + .andExpect(status().is2xxSuccessful()); + + List records = recordsOfDict(dict); + assertThat(records).hasSize(1); + assertThat(records.get(0).getGeometry()).isNull(); + } + + // ---------- helpers ---------- + + /** Достаёт все записи словаря из repository. Берём всё, dict свежий. */ + private List recordsOfDict(String dictName) { + UUID dictId = definitionRepo.findByName(dictName) + .orElseThrow(() -> new AssertionError("dict not found: " + dictName)) + .getId(); + return recordRepo.findAll().stream() + .filter(r -> dictId.equals(r.getDictionaryId())) + .toList(); + } + + /** Ожидает ровно одну запись в словаре с не-null Point geometry. */ + private Point assertOnePointSaved(String dict) { + List records = recordsOfDict(dict); + assertThat(records).hasSize(1); + Geometry g = records.get(0).getGeometry(); + assertThat(g).isNotNull().isInstanceOf(Point.class); + return (Point) g; + } +}