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