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