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:
+233
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user