test(geo): e2e auto-derive geometry из data.lat/lon — 6 кейсов

Покрытие resolveGeometry() в DictionaryRecordService через REST API
(MockMvc + Postgres testcontainer с PostGIS):

- lat/lon → POINT(lon lat) с SRID 4326 (Москва 55.75, 37.62)
- latitude/longitude alias → тот же flow (СПб 59.93, 30.31)
- lat/lng alias → то же (Токио 35.68, 139.65)
- explicit geometryWkt приоритет над data.lat/lon
- out-of-range lat (200) / lon (300) → silent skip, geometry == null
- no lat/lon → geometry == null

Закрепляет недавнюю работу (commit 9214ee9) после которой Liquibase
0015 backfill заработал на staging. AOI spatial filter теперь работает
на ground_station и similar dictionaries автоматически — этот тест
гарантирует что регрессий не будет.

Прогон: 20/20 e2e (было 14, +6).
This commit is contained in:
Zimin A.N.
2026-05-07 12:02:00 +03:00
parent 640a633722
commit 66a4f13942
@@ -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).
*
* <p>Тест отражает Liquibase migration 0015: backfill для legacy записей
* без geometry при наличии lat/lon в data — чтобы AOI spatial filter работал
* на ground_station и similar dictionaries.
*
* <p>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<DictionaryRecord> 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<DictionaryRecord> records = recordsOfDict(dict);
assertThat(records).hasSize(1);
assertThat(records.get(0).getGeometry()).isNull();
}
// ---------- helpers ----------
/** Достаёт все записи словаря из repository. Берём всё, dict свежий. */
private List<DictionaryRecord> 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<DictionaryRecord> records = recordsOfDict(dict);
assertThat(records).hasSize(1);
Geometry g = records.get(0).getGeometry();
assertThat(g).isNotNull().isInstanceOf(Point.class);
return (Point) g;
}
}