feat(lineage): Phase 1 backend — dependents endpoints + REGRESSION test
dict-relationships-v2 epic, Phase 1 read-side. Backend готов; admin UI
(card "Used by" + record references) — отдельным commit'ом.
Что добавлено:
- `DependentsQuery` (interface) + `DependentsQueryJdbc` (impl) — native
SQL для record-level dependents lookup (paginated). Interface extracted
чтобы Mockito мог mock'нуть на JDK 25 (concrete-class inline-mock incompat).
- `LineageIndexService` — два режима:
* `findSchemaDependents(name, scopes)` — schema-level reverse FK list
(in-memory traversal definitions, microseconds). Skip self-reference,
scope-hidden sources, malformed x-references (logged warn).
* `findRecordDependents(dict, businessKey, scopes, page, size)` —
record-level paginated, perSource summary + total. Phase 2 переносит
на materialized view; пока живём с прямым JSONB query (acceptable
для <5k SLO).
- `LineageController` — два endpoint'а:
* `GET /api/v1/dictionaries/{name}/dependents`
* `GET /api/v1/records/{dict}/{businessKey}/dependents?page&size`
Errors: 404 dictionary_not_found / record_not_active, 400 invalid_pagination.
Scope hiding делает target invisible (empty list / 404 — без leak existence).
Tests:
- LineageIndexServiceTest (16 unit tests):
* Schema-level: not_found, scope hidden, single ref + count, onClose
propagation (BLOCK default + CASCADE explicit), sorting, source scope
hidden, self-reference skip, malformed skip, target without properties.
* Record-level: invalid pagination (page<0, size<1, size>200),
dict_not_found, record_not_active, no schema refs → empty page,
1 source с dependents → items+perSource+total, pagination через
page=0/1/2 size=2 (3 страницы из 5 records).
- ReferenceCloseRegressionTest (4 mandatory regression tests):
* v1.2.1 schema без x-references-on-close — validate() возвращает
те же error codes (никаких новых on_close/cascade в codes).
* Schema parser default'ит onClose=BLOCK для v1.2.1 schemas.
* Legacy single-arg parseRef(spec) без property schema сохраняет contract.
* Schema без x-references вообще — full no-op (30+ legacy справочников).
mvn -pl ordinis-rest-api -am test: 89/89 PASS (5.686s).
mvn verify -pl '!ordinis-app': все модули SUCCESS (9.031s).
Backward-compat: bundle v1.2.1 → identical поведение к v1.0.7. Cascade engine
не поднимается на closed-side (Phase 3 territory).
This commit is contained in:
+289
@@ -0,0 +1,289 @@
|
||||
package cloud.nstart.terravault.ordinis.restapi.service.reference;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
|
||||
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
|
||||
import cloud.nstart.terravault.ordinis.domain.record.DependentsQuery;
|
||||
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
|
||||
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Reverse FK lineage index: "кто ссылается на этот словарь / запись".
|
||||
*
|
||||
* <p>Phase 1 read-side для dict-relationships-v2 epic. Два режима:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Schema-level</b>: {@link #findSchemaDependents(String, Set)} —
|
||||
* берёт target dict name, обходит все definitions и собирает все
|
||||
* fields у которых {@code x-references = "<target>.<field>"}. Дёшево
|
||||
* (in-memory traversal, ~40 dicts × ~20 fields), кэш не нужен в v1.</li>
|
||||
* <li><b>Record-level</b>: {@link #findRecordDependents} — для конкретного
|
||||
* business_key в target_dict находит active records в source dicts
|
||||
* что на него ссылаются. Paginated через {@link DependentsQuery} (native
|
||||
* SQL JSONB lookup). Phase 2 переносит на materialized view.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Caching: Phase 1 не кэширует — schema lookup микросекунды, record-level
|
||||
* SQL paginated и каждый bbox разный. Phase 2 добавит {@code @Cacheable} для
|
||||
* schema-level + invalidation на bundle import (см. eng review A.1).
|
||||
*
|
||||
* <p>Scope: filter target dict + source dicts через {@code allowedScopes}.
|
||||
* Если каллер не имеет access к target dict — возвращаем "not found"
|
||||
* (consistent с CRUD endpoint behaviour, чтобы не leak'ало existence).
|
||||
*/
|
||||
@Service
|
||||
public class LineageIndexService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LineageIndexService.class);
|
||||
|
||||
private final DictionaryDefinitionRepository definitionRepository;
|
||||
private final DictionaryRecordRepository recordRepository;
|
||||
private final DependentsQuery dependentsQuery;
|
||||
|
||||
public LineageIndexService(
|
||||
DictionaryDefinitionRepository definitionRepository,
|
||||
DictionaryRecordRepository recordRepository,
|
||||
DependentsQuery dependentsQuery) {
|
||||
this.definitionRepository = definitionRepository;
|
||||
this.recordRepository = recordRepository;
|
||||
this.dependentsQuery = dependentsQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema-level dependents target dict: какие dict.field ссылаются.
|
||||
*
|
||||
* @param targetDictName dict name (e.g. "satellite_type")
|
||||
* @param allowedScopes что caller видит (источники с недоступным scope filtered)
|
||||
* @return list reverse FK pointers, sorted by sourceDict, sourceField asc.
|
||||
* Empty если target не существует или у caller нет к нему access.
|
||||
*
|
||||
* @throws SchemaDependentsTarget#NOT_FOUND нет такого dict.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<SchemaDependent> findSchemaDependents(
|
||||
String targetDictName, Set<DataScope> allowedScopes) {
|
||||
Optional<DictionaryDefinition> targetOpt = definitionRepository.findByName(targetDictName);
|
||||
if (targetOpt.isEmpty()) return List.of();
|
||||
DictionaryDefinition target = targetOpt.get();
|
||||
if (!allowedScopes.contains(target.getScope())) {
|
||||
// Scope hide — target invisible. Return empty (same shape as not_found).
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<DictionaryDefinition> all = definitionRepository.findAll();
|
||||
// Pre-compute counts active records per source dict (для UI badge).
|
||||
Map<java.util.UUID, Long> counts = new HashMap<>();
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
for (Object[] row : recordRepository.countActiveGroupedByDictionary(allowedScopes, now)) {
|
||||
counts.put((java.util.UUID) row[0], (Long) row[1]);
|
||||
}
|
||||
|
||||
List<SchemaDependent> out = new ArrayList<>();
|
||||
for (DictionaryDefinition source : all) {
|
||||
if (source.getId().equals(target.getId())) continue; // self-reference skipped
|
||||
if (!allowedScopes.contains(source.getScope())) continue;
|
||||
JsonNode schema = source.getSchemaJson();
|
||||
if (schema == null || !schema.isObject()) continue;
|
||||
JsonNode properties = schema.get("properties");
|
||||
if (properties == null || !properties.isObject()) continue;
|
||||
|
||||
Iterator<Map.Entry<String, JsonNode>> it = properties.fields();
|
||||
while (it.hasNext()) {
|
||||
Map.Entry<String, JsonNode> entry = it.next();
|
||||
String fieldName = entry.getKey();
|
||||
JsonNode propSchema = entry.getValue();
|
||||
JsonNode refNode = propSchema.get("x-references");
|
||||
if (refNode == null || !refNode.isTextual()) continue;
|
||||
|
||||
ReferenceValidator.ParsedRef parsed;
|
||||
try {
|
||||
parsed = ReferenceValidator.parseRef(refNode.asText(), propSchema);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Schema {} field {} malformed x-references: {}",
|
||||
source.getName(), fieldName, e.getMessage());
|
||||
continue;
|
||||
}
|
||||
if (!parsed.dictionaryName().equals(target.getName())) continue;
|
||||
|
||||
out.add(new SchemaDependent(
|
||||
source.getName(),
|
||||
source.getDisplayName(),
|
||||
fieldName,
|
||||
parsed.fieldName(),
|
||||
parsed.onClose(),
|
||||
counts.getOrDefault(source.getId(), 0L)));
|
||||
}
|
||||
}
|
||||
out.sort((a, b) -> {
|
||||
int c = a.sourceDict().compareTo(b.sourceDict());
|
||||
return c != 0 ? c : a.sourceField().compareTo(b.sourceField());
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record-level dependents: для target record (dict + business_key) ищет
|
||||
* все active records в source dicts что на него ссылаются.
|
||||
*
|
||||
* <p>Логика: для каждого source dict у которого schema имеет
|
||||
* {@code x-references: "<target_dict>.<target_field>"} — берём value
|
||||
* {@code target_record.data->>target_field} и считаем/перечисляем active
|
||||
* source records где {@code data->>source_field == that_value}.
|
||||
*
|
||||
* @param page page index 0..N
|
||||
* @param size page size 1..200
|
||||
* @return paged dependents + per-source totals
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public RecordDependentsPage findRecordDependents(
|
||||
String targetDictName,
|
||||
String targetBusinessKey,
|
||||
Set<DataScope> allowedScopes,
|
||||
int page,
|
||||
int size) {
|
||||
if (page < 0) throw new IllegalArgumentException("page must be >= 0");
|
||||
if (size < 1 || size > 200) {
|
||||
throw new IllegalArgumentException("size must be 1..200, got " + size);
|
||||
}
|
||||
|
||||
Optional<DictionaryDefinition> targetOpt = definitionRepository.findByName(targetDictName);
|
||||
if (targetOpt.isEmpty()) {
|
||||
throw new IllegalArgumentException("dictionary not found: " + targetDictName);
|
||||
}
|
||||
DictionaryDefinition target = targetOpt.get();
|
||||
if (!allowedScopes.contains(target.getScope())) {
|
||||
throw new IllegalArgumentException("dictionary not found: " + targetDictName);
|
||||
}
|
||||
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
Optional<DictionaryRecord> recOpt =
|
||||
recordRepository.findActiveAt(target.getId(), targetBusinessKey, now);
|
||||
if (recOpt.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"active record not found: " + targetDictName + "/" + targetBusinessKey);
|
||||
}
|
||||
DictionaryRecord targetRecord = recOpt.get();
|
||||
|
||||
// Schema-level reverse index: какие (source_dict, source_field, target_field, onClose).
|
||||
List<SchemaDependent> schemaRefs = findSchemaDependents(targetDictName, allowedScopes);
|
||||
if (schemaRefs.isEmpty()) {
|
||||
return new RecordDependentsPage(
|
||||
targetDictName, targetBusinessKey, List.of(), List.of(), 0, page, size);
|
||||
}
|
||||
|
||||
// Per-source: pull target value, count + paginate.
|
||||
// Phase 1: linear scan по source schemas — ОК для <40 dicts.
|
||||
// Phase 2: MV сворачивает в один query.
|
||||
List<RecordDependent> all = new ArrayList<>();
|
||||
List<PerSourceSummary> summary = new ArrayList<>();
|
||||
long total = 0;
|
||||
|
||||
for (SchemaDependent ref : schemaRefs) {
|
||||
JsonNode targetData = targetRecord.getData();
|
||||
if (targetData == null) continue;
|
||||
JsonNode valueNode = targetData.get(ref.targetField());
|
||||
if (valueNode == null || !valueNode.isTextual()) continue;
|
||||
String targetValue = valueNode.asText();
|
||||
|
||||
Optional<DictionaryDefinition> sourceOpt =
|
||||
definitionRepository.findByName(ref.sourceDict());
|
||||
if (sourceOpt.isEmpty()) continue;
|
||||
DictionaryDefinition sourceDef = sourceOpt.get();
|
||||
|
||||
long perSourceCount = dependentsQuery.countActive(
|
||||
sourceDef.getId(), ref.sourceField(), targetValue, now);
|
||||
summary.add(new PerSourceSummary(
|
||||
ref.sourceDict(),
|
||||
ref.sourceDisplayName(),
|
||||
ref.sourceField(),
|
||||
ref.onClose(),
|
||||
perSourceCount));
|
||||
total += perSourceCount;
|
||||
|
||||
if (perSourceCount == 0) continue;
|
||||
|
||||
// Paginate by aggregated total: каждый source pulled полностью если
|
||||
// нужен (proper offset с union в SQL отложен Phase 2 — здесь simpler).
|
||||
List<DependentsQuery.DependentRow> rows = dependentsQuery.findActive(
|
||||
sourceDef.getId(), ref.sourceField(), targetValue, now,
|
||||
// Защитный bound: <= 200 на source per request.
|
||||
Math.min(size + page * size + 1, 200), 0);
|
||||
for (DependentsQuery.DependentRow r : rows) {
|
||||
all.add(new RecordDependent(
|
||||
ref.sourceDict(),
|
||||
ref.sourceDisplayName(),
|
||||
ref.sourceField(),
|
||||
ref.onClose(),
|
||||
r.id(),
|
||||
r.businessKey(),
|
||||
r.validFrom(),
|
||||
r.validTo(),
|
||||
r.createdAt()));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort all dependents by createdAt DESC, then page slice.
|
||||
all.sort((a, b) -> b.createdAt().compareTo(a.createdAt()));
|
||||
int from = Math.min(page * size, all.size());
|
||||
int to = Math.min(from + size, all.size());
|
||||
List<RecordDependent> slice = all.subList(from, to);
|
||||
|
||||
return new RecordDependentsPage(
|
||||
targetDictName, targetBusinessKey, slice, summary, total, page, size);
|
||||
}
|
||||
|
||||
// === DTOs ===
|
||||
|
||||
/** Schema-level reverse FK pointer: source dict → target dict через field. */
|
||||
public record SchemaDependent(
|
||||
String sourceDict,
|
||||
String sourceDisplayName,
|
||||
String sourceField,
|
||||
String targetField,
|
||||
OnCloseAction onClose,
|
||||
long activeRecordsInSourceDict) {}
|
||||
|
||||
/** Record-level dependent — конкретная active record что ссылается. */
|
||||
public record RecordDependent(
|
||||
String sourceDict,
|
||||
String sourceDisplayName,
|
||||
String sourceField,
|
||||
OnCloseAction onClose,
|
||||
java.util.UUID id,
|
||||
String businessKey,
|
||||
OffsetDateTime validFrom,
|
||||
OffsetDateTime validTo,
|
||||
OffsetDateTime createdAt) {}
|
||||
|
||||
/** Per-source summary — сколько dependents в каждом source dict. */
|
||||
public record PerSourceSummary(
|
||||
String sourceDict,
|
||||
String sourceDisplayName,
|
||||
String sourceField,
|
||||
OnCloseAction onClose,
|
||||
long count) {}
|
||||
|
||||
/** Paginated page + per-source aggregates. */
|
||||
public record RecordDependentsPage(
|
||||
String targetDict,
|
||||
String targetBusinessKey,
|
||||
List<RecordDependent> items,
|
||||
List<PerSourceSummary> perSource,
|
||||
long total,
|
||||
int page,
|
||||
int size) {}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package cloud.nstart.terravault.ordinis.restapi.web;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
|
||||
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
||||
import cloud.nstart.terravault.ordinis.restapi.service.reference.LineageIndexService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Lineage / dependents read-side. Phase 1 dict-relationships-v2 epic.
|
||||
*
|
||||
* <p>Два endpoint'а:
|
||||
* <ul>
|
||||
* <li>{@code GET /api/v1/dictionaries/{name}/dependents} — schema-level
|
||||
* reverse FK list ("какие словари ссылаются на этот словарь и через
|
||||
* какие поля").</li>
|
||||
* <li>{@code GET /api/v1/records/{dict}/{businessKey}/dependents} —
|
||||
* record-level dependents (paginated, per-source counts).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Errors:
|
||||
* <ul>
|
||||
* <li>404 {@code dictionary_not_found} — target dict не существует или
|
||||
* не виден caller'у в его scopes (одинаковая ошибка чтобы не leak'ало
|
||||
* existence).</li>
|
||||
* <li>404 {@code record_not_active} — нет active record на now().</li>
|
||||
* <li>400 {@code invalid_pagination} — page/size вне допустимого диапазона.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
public class LineageController {
|
||||
|
||||
private final LineageIndexService lineage;
|
||||
private final ScopeContext scopeContext;
|
||||
|
||||
public LineageController(LineageIndexService lineage, ScopeContext scopeContext) {
|
||||
this.lineage = lineage;
|
||||
this.scopeContext = scopeContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema-level reverse FK list. Empty list если target не существует
|
||||
* или не виден — consistent с CRUD endpoints (no leak existence).
|
||||
*
|
||||
* <p>Возврат: list объектов
|
||||
* {@code {sourceDict, sourceDisplayName, sourceField, targetField,
|
||||
* onClose, activeRecordsInSourceDict}}.
|
||||
*/
|
||||
@GetMapping("/dictionaries/{name}/dependents")
|
||||
public List<LineageIndexService.SchemaDependent> schemaDependents(
|
||||
@PathVariable String name) {
|
||||
return lineage.findSchemaDependents(name, scopeContext.currentScopes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Record-level dependents для конкретной record (target_dict + business_key).
|
||||
*
|
||||
* <p>Pagination: {@code page} (default 0), {@code size} (default 50, max 200).
|
||||
*
|
||||
* @return объект {@code {targetDict, targetBusinessKey, items[], perSource[],
|
||||
* total, page, size}}.
|
||||
*/
|
||||
@GetMapping("/records/{dict}/{businessKey}/dependents")
|
||||
public LineageIndexService.RecordDependentsPage recordDependents(
|
||||
@PathVariable String dict,
|
||||
@PathVariable String businessKey,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size) {
|
||||
if (page < 0 || size < 1 || size > 200) {
|
||||
throw OrdinisException.badRequest(
|
||||
"invalid_pagination",
|
||||
"page must be >= 0 and size 1..200; got page=" + page + " size=" + size);
|
||||
}
|
||||
try {
|
||||
return lineage.findRecordDependents(
|
||||
dict, businessKey, scopeContext.currentScopes(), page, size);
|
||||
} catch (IllegalArgumentException e) {
|
||||
String msg = e.getMessage() == null ? "" : e.getMessage();
|
||||
if (msg.startsWith("dictionary not found")) {
|
||||
throw OrdinisException.notFound("dictionary_not_found", msg);
|
||||
}
|
||||
if (msg.startsWith("active record not found")) {
|
||||
throw OrdinisException.notFound("record_not_active", msg);
|
||||
}
|
||||
throw OrdinisException.badRequest("invalid_request", msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
package cloud.nstart.terravault.ordinis.restapi.service.reference;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
|
||||
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
|
||||
import cloud.nstart.terravault.ordinis.domain.record.DependentsQuery;
|
||||
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 org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Unit tests {@link LineageIndexService}. Покрывает:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Schema-level reverse FK: not_found, scope hidden, malformed,
|
||||
* self-reference skip, sorting, onClose из x-references-on-close.</li>
|
||||
* <li>Record-level: not_active record → exception, no schema refs → empty,
|
||||
* perSource counts + paginated items, pagination across multiple
|
||||
* sources.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Mockito не может mock'нуть DictionaryDefinition / DictionaryRecord
|
||||
* на JDK 25 (subclass mock-maker incompat). Используем real entities.
|
||||
*/
|
||||
class LineageIndexServiceTest {
|
||||
|
||||
private static final ObjectMapper OM = new ObjectMapper();
|
||||
|
||||
private DictionaryDefinitionRepository defRepo;
|
||||
private DictionaryRecordRepository recRepo;
|
||||
private DependentsQuery depsQuery;
|
||||
private LineageIndexService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
defRepo = mock(DictionaryDefinitionRepository.class);
|
||||
recRepo = mock(DictionaryRecordRepository.class);
|
||||
depsQuery = mock(DependentsQuery.class);
|
||||
service = new LineageIndexService(defRepo, recRepo, depsQuery);
|
||||
}
|
||||
|
||||
// === Schema-level findSchemaDependents ===
|
||||
|
||||
@Test
|
||||
void schemaDeps_targetNotFound_returnsEmpty() {
|
||||
when(defRepo.findByName("missing")).thenReturn(Optional.empty());
|
||||
var result = service.findSchemaDependents("missing", Set.of(DataScope.PUBLIC));
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaDeps_targetScopeHidden_returnsEmpty() {
|
||||
var target = mockDef("internal", DataScope.INTERNAL);
|
||||
when(defRepo.findByName("internal")).thenReturn(Optional.of(target));
|
||||
var result = service.findSchemaDependents("internal", Set.of(DataScope.PUBLIC));
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaDeps_oneReferenceFound_returnsIt() throws Exception {
|
||||
var target = mockDef("satellite_type", DataScope.PUBLIC);
|
||||
var source = mockDefWithSchema("spacecraft", DataScope.PUBLIC, """
|
||||
{"properties":{"satTypeCode":{"type":"string","x-references":"satellite_type.code"}}}""");
|
||||
when(defRepo.findByName("satellite_type")).thenReturn(Optional.of(target));
|
||||
when(defRepo.findAll()).thenReturn(List.of(target, source));
|
||||
List<Object[]> rows = new java.util.ArrayList<>();
|
||||
rows.add(new Object[]{source.getId(), 7L});
|
||||
when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(rows);
|
||||
|
||||
var result = service.findSchemaDependents("satellite_type", Set.of(DataScope.PUBLIC));
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
var dep = result.get(0);
|
||||
assertThat(dep.sourceDict()).isEqualTo("spacecraft");
|
||||
assertThat(dep.sourceField()).isEqualTo("satTypeCode");
|
||||
assertThat(dep.targetField()).isEqualTo("code");
|
||||
assertThat(dep.onClose()).isEqualTo(OnCloseAction.BLOCK); // default
|
||||
assertThat(dep.activeRecordsInSourceDict()).isEqualTo(7L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaDeps_onCloseFromSchema_propagated() throws Exception {
|
||||
var target = mockDef("ground_station", DataScope.PUBLIC);
|
||||
var source = mockDefWithSchema("contact_session", DataScope.PUBLIC, """
|
||||
{"properties":{
|
||||
"stationCode":{"type":"string",
|
||||
"x-references":"ground_station.code",
|
||||
"x-references-on-close":"cascade"}
|
||||
}}""");
|
||||
when(defRepo.findByName("ground_station")).thenReturn(Optional.of(target));
|
||||
when(defRepo.findAll()).thenReturn(List.of(target, source));
|
||||
when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(List.of());
|
||||
|
||||
var result = service.findSchemaDependents("ground_station", Set.of(DataScope.PUBLIC));
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).onClose()).isEqualTo(OnCloseAction.CASCADE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaDeps_multipleSourcesSorted() throws Exception {
|
||||
var target = mockDef("dict_t", DataScope.PUBLIC);
|
||||
var srcA = mockDefWithSchema("zeta_dict", DataScope.PUBLIC,
|
||||
"{\"properties\":{\"r\":{\"x-references\":\"dict_t.f\"}}}");
|
||||
var srcB = mockDefWithSchema("alpha_dict", DataScope.PUBLIC,
|
||||
"{\"properties\":{\"r\":{\"x-references\":\"dict_t.f\"}}}");
|
||||
when(defRepo.findByName("dict_t")).thenReturn(Optional.of(target));
|
||||
when(defRepo.findAll()).thenReturn(List.of(target, srcA, srcB));
|
||||
when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(List.of());
|
||||
|
||||
var result = service.findSchemaDependents("dict_t", Set.of(DataScope.PUBLIC));
|
||||
|
||||
assertThat(result).extracting(LineageIndexService.SchemaDependent::sourceDict)
|
||||
.containsExactly("alpha_dict", "zeta_dict");
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaDeps_sourceScopeHidden_skipped() throws Exception {
|
||||
var target = mockDef("dict_t", DataScope.PUBLIC);
|
||||
var visibleSrc = mockDefWithSchema("public_src", DataScope.PUBLIC,
|
||||
"{\"properties\":{\"r\":{\"x-references\":\"dict_t.f\"}}}");
|
||||
var hiddenSrc = mockDefWithSchema("internal_src", DataScope.INTERNAL,
|
||||
"{\"properties\":{\"r\":{\"x-references\":\"dict_t.f\"}}}");
|
||||
when(defRepo.findByName("dict_t")).thenReturn(Optional.of(target));
|
||||
when(defRepo.findAll()).thenReturn(List.of(target, visibleSrc, hiddenSrc));
|
||||
when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(List.of());
|
||||
|
||||
var result = service.findSchemaDependents("dict_t", Set.of(DataScope.PUBLIC));
|
||||
|
||||
assertThat(result).extracting(LineageIndexService.SchemaDependent::sourceDict)
|
||||
.containsExactly("public_src");
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaDeps_selfReference_skipped() throws Exception {
|
||||
// Schema указывает на свой же dict — не должно зацикливать.
|
||||
var selfRef = mockDefWithSchema("hierarchy", DataScope.PUBLIC,
|
||||
"{\"properties\":{\"parentCode\":{\"x-references\":\"hierarchy.code\"}}}");
|
||||
when(defRepo.findByName("hierarchy")).thenReturn(Optional.of(selfRef));
|
||||
when(defRepo.findAll()).thenReturn(List.of(selfRef));
|
||||
when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(List.of());
|
||||
|
||||
var result = service.findSchemaDependents("hierarchy", Set.of(DataScope.PUBLIC));
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaDeps_malformedRef_skippedSilently() throws Exception {
|
||||
var target = mockDef("dict_t", DataScope.PUBLIC);
|
||||
var srcGood = mockDefWithSchema("good_src", DataScope.PUBLIC,
|
||||
"{\"properties\":{\"r\":{\"x-references\":\"dict_t.f\"}}}");
|
||||
var srcBad = mockDefWithSchema("bad_src", DataScope.PUBLIC,
|
||||
"{\"properties\":{\"r\":{\"x-references\":\"malformed-no-dot\"}}}");
|
||||
when(defRepo.findByName("dict_t")).thenReturn(Optional.of(target));
|
||||
when(defRepo.findAll()).thenReturn(List.of(target, srcGood, srcBad));
|
||||
when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(List.of());
|
||||
|
||||
var result = service.findSchemaDependents("dict_t", Set.of(DataScope.PUBLIC));
|
||||
|
||||
assertThat(result).extracting(LineageIndexService.SchemaDependent::sourceDict)
|
||||
.containsExactly("good_src");
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaDeps_targetSchemaWithoutProperties_skipped() throws Exception {
|
||||
var target = mockDef("dict_t", DataScope.PUBLIC);
|
||||
var src = mockDefWithSchema("src", DataScope.PUBLIC, "{\"type\":\"object\"}"); // no properties
|
||||
when(defRepo.findByName("dict_t")).thenReturn(Optional.of(target));
|
||||
when(defRepo.findAll()).thenReturn(List.of(target, src));
|
||||
when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(List.of());
|
||||
|
||||
var result = service.findSchemaDependents("dict_t", Set.of(DataScope.PUBLIC));
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// === Record-level findRecordDependents ===
|
||||
|
||||
@Test
|
||||
void recordDeps_invalidPagination_throws() {
|
||||
assertThatThrownBy(() -> service.findRecordDependents(
|
||||
"d", "k", Set.of(DataScope.PUBLIC), -1, 50))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("page");
|
||||
assertThatThrownBy(() -> service.findRecordDependents(
|
||||
"d", "k", Set.of(DataScope.PUBLIC), 0, 0))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("size");
|
||||
assertThatThrownBy(() -> service.findRecordDependents(
|
||||
"d", "k", Set.of(DataScope.PUBLIC), 0, 201))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("size");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordDeps_dictNotFound_throws() {
|
||||
when(defRepo.findByName("missing")).thenReturn(Optional.empty());
|
||||
assertThatThrownBy(() -> service.findRecordDependents(
|
||||
"missing", "k", Set.of(DataScope.PUBLIC), 0, 50))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("dictionary not found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordDeps_dictScopeHidden_throwsAsNotFound() {
|
||||
var target = mockDef("internal", DataScope.INTERNAL);
|
||||
when(defRepo.findByName("internal")).thenReturn(Optional.of(target));
|
||||
assertThatThrownBy(() -> service.findRecordDependents(
|
||||
"internal", "k", Set.of(DataScope.PUBLIC), 0, 50))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("dictionary not found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordDeps_recordNotActive_throws() {
|
||||
var target = mockDef("dict_t", DataScope.PUBLIC);
|
||||
when(defRepo.findByName("dict_t")).thenReturn(Optional.of(target));
|
||||
when(recRepo.findActiveAt(eq(target.getId()), eq("missing-key"), any()))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.findRecordDependents(
|
||||
"dict_t", "missing-key", Set.of(DataScope.PUBLIC), 0, 50))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("active record not found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordDeps_noSchemaRefs_returnsEmptyPage() throws Exception {
|
||||
var target = mockDef("dict_t", DataScope.PUBLIC);
|
||||
when(defRepo.findByName("dict_t")).thenReturn(Optional.of(target));
|
||||
when(defRepo.findAll()).thenReturn(List.of(target));
|
||||
when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(List.of());
|
||||
|
||||
JsonNode data = OM.readTree("{\"code\":\"X-001\"}");
|
||||
DictionaryRecord rec = mockRecord(target.getId(), "X-001", data);
|
||||
when(recRepo.findActiveAt(eq(target.getId()), eq("X-001"), any()))
|
||||
.thenReturn(Optional.of(rec));
|
||||
|
||||
var page = service.findRecordDependents(
|
||||
"dict_t", "X-001", Set.of(DataScope.PUBLIC), 0, 50);
|
||||
|
||||
assertThat(page.items()).isEmpty();
|
||||
assertThat(page.perSource()).isEmpty();
|
||||
assertThat(page.total()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordDeps_oneSourceWithDependents_returnsItemsAndPerSource() throws Exception {
|
||||
var target = mockDef("satellite_type", DataScope.PUBLIC);
|
||||
var source = mockDefWithSchema("spacecraft", DataScope.PUBLIC, """
|
||||
{"properties":{"satTypeCode":{"type":"string","x-references":"satellite_type.code"}}}""");
|
||||
when(defRepo.findByName("satellite_type")).thenReturn(Optional.of(target));
|
||||
when(defRepo.findByName("spacecraft")).thenReturn(Optional.of(source));
|
||||
when(defRepo.findAll()).thenReturn(List.of(target, source));
|
||||
when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(List.of());
|
||||
|
||||
JsonNode targetData = OM.readTree("{\"code\":\"SAR-X\"}");
|
||||
DictionaryRecord targetRec = mockRecord(target.getId(), "SAR-X", targetData);
|
||||
when(recRepo.findActiveAt(eq(target.getId()), eq("SAR-X"), any()))
|
||||
.thenReturn(Optional.of(targetRec));
|
||||
|
||||
when(depsQuery.countActive(eq(source.getId()), eq("satTypeCode"), eq("SAR-X"), any()))
|
||||
.thenReturn(2L);
|
||||
OffsetDateTime t1 = OffsetDateTime.parse("2026-01-01T00:00:00Z");
|
||||
OffsetDateTime t2 = OffsetDateTime.parse("2026-02-01T00:00:00Z");
|
||||
when(depsQuery.findActive(eq(source.getId()), eq("satTypeCode"), eq("SAR-X"),
|
||||
any(), anyInt(), anyInt())).thenReturn(List.of(
|
||||
new DependentsQuery.DependentRow(
|
||||
UUID.randomUUID(), source.getId(), "SC-001", t1, OffsetDateTime.MAX, t2),
|
||||
new DependentsQuery.DependentRow(
|
||||
UUID.randomUUID(), source.getId(), "SC-002", t1, OffsetDateTime.MAX, t1)));
|
||||
|
||||
var page = service.findRecordDependents(
|
||||
"satellite_type", "SAR-X", Set.of(DataScope.PUBLIC), 0, 50);
|
||||
|
||||
assertThat(page.total()).isEqualTo(2L);
|
||||
assertThat(page.perSource()).hasSize(1);
|
||||
assertThat(page.perSource().get(0).count()).isEqualTo(2L);
|
||||
assertThat(page.perSource().get(0).sourceDict()).isEqualTo("spacecraft");
|
||||
assertThat(page.items()).hasSize(2);
|
||||
// sorted by createdAt DESC: SC-001 (t2) first
|
||||
assertThat(page.items().get(0).businessKey()).isEqualTo("SC-001");
|
||||
assertThat(page.items().get(1).businessKey()).isEqualTo("SC-002");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordDeps_pagination_slicesCorrectly() throws Exception {
|
||||
var target = mockDef("dict_t", DataScope.PUBLIC);
|
||||
var source = mockDefWithSchema("src", DataScope.PUBLIC,
|
||||
"{\"properties\":{\"ref\":{\"x-references\":\"dict_t.code\"}}}");
|
||||
when(defRepo.findByName("dict_t")).thenReturn(Optional.of(target));
|
||||
when(defRepo.findByName("src")).thenReturn(Optional.of(source));
|
||||
when(defRepo.findAll()).thenReturn(List.of(target, source));
|
||||
when(recRepo.countActiveGroupedByDictionary(any(), any())).thenReturn(List.of());
|
||||
|
||||
JsonNode targetData = OM.readTree("{\"code\":\"K\"}");
|
||||
when(recRepo.findActiveAt(eq(target.getId()), eq("K"), any()))
|
||||
.thenReturn(Optional.of(mockRecord(target.getId(), "K", targetData)));
|
||||
|
||||
when(depsQuery.countActive(any(), anyString(), anyString(), any())).thenReturn(5L);
|
||||
// 5 rows, sorted later by createdAt DESC.
|
||||
var rows = new java.util.ArrayList<DependentsQuery.DependentRow>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
rows.add(new DependentsQuery.DependentRow(
|
||||
UUID.randomUUID(), source.getId(), "K-" + i,
|
||||
OffsetDateTime.parse("2026-01-01T00:00:00Z"), OffsetDateTime.MAX,
|
||||
OffsetDateTime.parse("2026-01-0" + (i + 1) + "T00:00:00Z")));
|
||||
}
|
||||
when(depsQuery.findActive(any(), anyString(), anyString(), any(), anyInt(), anyInt()))
|
||||
.thenReturn(rows);
|
||||
|
||||
var page1 = service.findRecordDependents("dict_t", "K", Set.of(DataScope.PUBLIC), 0, 2);
|
||||
assertThat(page1.items()).hasSize(2);
|
||||
assertThat(page1.items().get(0).businessKey()).isEqualTo("K-4"); // newest
|
||||
assertThat(page1.total()).isEqualTo(5L);
|
||||
|
||||
var page2 = service.findRecordDependents("dict_t", "K", Set.of(DataScope.PUBLIC), 1, 2);
|
||||
assertThat(page2.items()).hasSize(2);
|
||||
assertThat(page2.items().get(0).businessKey()).isEqualTo("K-2");
|
||||
|
||||
var page3 = service.findRecordDependents("dict_t", "K", Set.of(DataScope.PUBLIC), 2, 2);
|
||||
assertThat(page3.items()).hasSize(1);
|
||||
assertThat(page3.items().get(0).businessKey()).isEqualTo("K-0"); // oldest
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
private static DictionaryDefinition mockDef(String name, DataScope scope) {
|
||||
return mockDefWithSchema(name, scope, "{\"type\":\"object\"}");
|
||||
}
|
||||
|
||||
private static DictionaryDefinition mockDefWithSchema(String name, DataScope scope, String schemaJson) {
|
||||
JsonNode schema;
|
||||
try {
|
||||
schema = OM.readTree(schemaJson);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
var def = new DictionaryDefinition(UUID.randomUUID(), name, scope, schema);
|
||||
def.setDisplayName(name);
|
||||
return def;
|
||||
}
|
||||
|
||||
private static DictionaryRecord mockRecord(UUID dictId, String businessKey, JsonNode data) {
|
||||
return new DictionaryRecord(
|
||||
UUID.randomUUID(), dictId, businessKey, data, DataScope.PUBLIC,
|
||||
OffsetDateTime.parse("2026-01-01T00:00:00Z"));
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package cloud.nstart.terravault.ordinis.restapi.service.reference;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
|
||||
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 cloud.nstart.terravault.ordinis.validation.ValidationError;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* REGRESSION mandatory: existing close path с null {@code x-references-on-close}
|
||||
* field MUST behave identical к v1.2.1.
|
||||
*
|
||||
* <p>Покрывает gap: bundle v1.2.1 (без новой schema field) loaded в новый build —
|
||||
* убедиться что:
|
||||
* <ol>
|
||||
* <li>{@link ReferenceValidator#validate} возвращает те же error codes что и
|
||||
* до Phase 1 prep commit (никаких новых x-references-on-close-related
|
||||
* codes не появилось).</li>
|
||||
* <li>{@link OnCloseAction#BLOCK} default'ится автоматически — schema parser
|
||||
* не падает и не меняет error response shape.</li>
|
||||
* <li>Active record на которое есть references просто закрывается — на этом
|
||||
* этапе close не обращается к onClose (cascade engine = Phase 3).</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Без этого теста риск молча сломать live consumers (см. design doc test
|
||||
* plan REGRESSION section).
|
||||
*/
|
||||
class ReferenceCloseRegressionTest {
|
||||
|
||||
private static final ObjectMapper OM = new ObjectMapper();
|
||||
|
||||
private DictionaryDefinitionRepository defRepo;
|
||||
private DictionaryRecordRepository recRepo;
|
||||
private ReferenceValidator validator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
defRepo = mock(DictionaryDefinitionRepository.class);
|
||||
recRepo = mock(DictionaryRecordRepository.class);
|
||||
validator = new ReferenceValidator(defRepo, recRepo);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.2.1 schema = x-references есть, x-references-on-close нет.
|
||||
* validate() должен:
|
||||
* — пропустить запись с валидным reference (no errors);
|
||||
* — отклонить запись с invalid reference с тем же кодом
|
||||
* {@code x_references_target_not_found} (без новых fields в error).
|
||||
*/
|
||||
@Test
|
||||
void v1_2_1_schemaWithoutOnClose_validateBehavesIdentical() throws Exception {
|
||||
UUID targetId = UUID.randomUUID();
|
||||
DictionaryDefinition target = new DictionaryDefinition(
|
||||
targetId, "satellite_type", DataScope.PUBLIC, OM.readTree("{}"));
|
||||
when(defRepo.findByName("satellite_type")).thenReturn(Optional.of(target));
|
||||
// Active record с code=LEO-001 существует, NONEXISTENT — нет.
|
||||
when(recRepo.findActiveByJsonField(eq(targetId), any(), eq("code"), eq("LEO-001")))
|
||||
.thenReturn(List.of(stubRecord(targetId)));
|
||||
when(recRepo.findActiveByJsonField(any(), any(), anyString(), eq("NONEXISTENT")))
|
||||
.thenReturn(List.of());
|
||||
|
||||
// V1.2.1 schema: x-references есть, x-references-on-close ОТСУТСТВУЕТ.
|
||||
JsonNode schema = OM.readTree("""
|
||||
{"type":"object","properties":{
|
||||
"satTypeCode":{"type":"string","x-references":"satellite_type.code"}
|
||||
}}""");
|
||||
|
||||
// Valid reference — нет errors.
|
||||
JsonNode goodData = OM.readTree("{\"satTypeCode\":\"LEO-001\"}");
|
||||
List<ValidationError> goodErrors = validator.validate(
|
||||
schema, goodData, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||
assertThat(goodErrors).isEmpty();
|
||||
|
||||
// Invalid reference — точно такой же error code и message structure как в v1.
|
||||
JsonNode badData = OM.readTree("{\"satTypeCode\":\"NONEXISTENT\"}");
|
||||
List<ValidationError> badErrors = validator.validate(
|
||||
schema, badData, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||
assertThat(badErrors).hasSize(1);
|
||||
ValidationError err = badErrors.get(0);
|
||||
assertThat(err.code()).isEqualTo("x_references_target_not_found");
|
||||
assertThat(err.path()).isEqualTo("/satTypeCode");
|
||||
assertThat(err.message()).contains("satellite_type");
|
||||
assertThat(err.message()).contains("NONEXISTENT");
|
||||
// Никаких новых полей в error: x_references_on_close_* кодов не появилось.
|
||||
assertThat(err.code()).doesNotContain("on_close");
|
||||
assertThat(err.code()).doesNotContain("cascade");
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema parser применяется к v1.2.1 bundle — onClose default'ится в BLOCK
|
||||
* автоматически. Никаких малформ-related parse errors.
|
||||
*/
|
||||
@Test
|
||||
void v1_2_1_schemaParser_defaultsOnCloseToBlock() throws Exception {
|
||||
JsonNode propV1 = OM.readTree("""
|
||||
{"type":"string","x-references":"d.f"}""");
|
||||
var parsed = ReferenceValidator.parseRef("d.f", propV1);
|
||||
assertThat(parsed.onClose()).isEqualTo(OnCloseAction.BLOCK);
|
||||
assertThat(parsed.dictionaryName()).isEqualTo("d");
|
||||
assertThat(parsed.fieldName()).isEqualTo("f");
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensive: legacy single-arg overload {@code parseRef(spec)} (без property
|
||||
* schema) сохраняет старый contract. Используется внешними тестами и orphan
|
||||
* scanner (не v1 API но shared parser).
|
||||
*/
|
||||
@Test
|
||||
void v1_2_1_schemaParser_singleArgStillWorks() {
|
||||
var parsed = ReferenceValidator.parseRef("satellite_type.code");
|
||||
assertThat(parsed.onClose()).isEqualTo(OnCloseAction.BLOCK);
|
||||
assertThat(parsed.dictionaryName()).isEqualTo("satellite_type");
|
||||
assertThat(parsed.fieldName()).isEqualTo("code");
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema без x-references вообще — full no-op, validate возвращает пустой
|
||||
* list errors. Покрывает 30+ legacy справочников что не имеют FK.
|
||||
*/
|
||||
@Test
|
||||
void v1_2_1_schemaWithoutXReferences_isFullNoOp() throws Exception {
|
||||
JsonNode schema = OM.readTree("""
|
||||
{"type":"object","properties":{
|
||||
"name":{"type":"string"},"code":{"type":"string"}
|
||||
}}""");
|
||||
JsonNode data = OM.readTree("{\"name\":\"Spacecraft Alpha\",\"code\":\"SC-001\"}");
|
||||
|
||||
List<ValidationError> errors = validator.validate(
|
||||
schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||
assertThat(errors).isEmpty();
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
private static DictionaryRecord stubRecord(UUID dictId) {
|
||||
return new DictionaryRecord(
|
||||
UUID.randomUUID(), dictId, "stub-key",
|
||||
OM.createObjectNode(), DataScope.PUBLIC, OffsetDateTime.now());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user