feat(refs): cross-dictionary x-references validation (v2)
JSON Schema extension `x-references: "dict_name.field"` объявляет что
значение поля должно ссылаться на active record в указанном словаре.
На POST/PUT record service валидирует existence + scope visibility.
Пример:
{
"type":"object",
"properties":{
"satTypeCode":{
"type":"string",
"x-references":"satellite_type.code"
}
}
}
ReferenceValidator (ordinis-rest-api/service/reference/):
- Walks schema properties, finds x-references markers
- Для каждого ref: lookup target dictionary + active record by JSONB
field value (использует existing findActiveByJsonField repo method)
- Returns ValidationError list, intergrates с RecordValidator pipeline
через DictionaryRecordService.validate()
- Scope hide: target dict не accessible → "not_found" error (не
"scope_denied"), чтобы не leak'ало existence
- Optional поля: если value missing, skip (JSON Schema validation
отдельно отлавливает required)
Error codes:
- x_references_malformed (spec не "dict.field")
- x_references_dict_not_found (target dictionary не существует)
- x_references_target_not_found (referenced record нет / scope hidden)
- x_references_non_string (v1 поддерживает только string values)
v1 ограничения (документированы в JavaDoc):
- Top-level fields only (nested objects не поддержаны)
- Only string values
- No cascade on delete/close (orphan FK alert — v2.5)
Tests (15 в ReferenceValidatorTest):
- parseRef: valid, empty, missing dot, trailing dot, nested paths
- validate: no refs, missing value (optional skip), existing record OK,
missing record error, missing dictionary error, scope-hidden error,
non-string value error, malformed spec error, multiple fields
validated independently, null schema graceful
Total project tests: 176 → 191.
Note: Mockito не может mock entity classes на JDK 25 (subclass
mock-maker limitation). Используем real entity ctor для
DictionaryDefinition + DictionaryRecord в тестах.
This commit is contained in:
+15
@@ -43,6 +43,8 @@ public class DictionaryRecordService {
|
||||
private final DictionaryRecordRepository repository;
|
||||
private final DictionaryDefinitionService definitionService;
|
||||
private final RecordValidator validator;
|
||||
private final cloud.nstart.terravault.ordinis.restapi.service.reference.ReferenceValidator referenceValidator;
|
||||
private final cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext;
|
||||
private final OutboxRecorder outbox;
|
||||
private final AuditLogger auditLogger;
|
||||
|
||||
@@ -52,11 +54,15 @@ public class DictionaryRecordService {
|
||||
DictionaryRecordRepository repository,
|
||||
DictionaryDefinitionService definitionService,
|
||||
RecordValidator validator,
|
||||
cloud.nstart.terravault.ordinis.restapi.service.reference.ReferenceValidator referenceValidator,
|
||||
cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext,
|
||||
OutboxRecorder outbox,
|
||||
AuditLogger auditLogger) {
|
||||
this.repository = repository;
|
||||
this.definitionService = definitionService;
|
||||
this.validator = validator;
|
||||
this.referenceValidator = referenceValidator;
|
||||
this.scopeContext = scopeContext;
|
||||
this.outbox = outbox;
|
||||
this.auditLogger = auditLogger;
|
||||
}
|
||||
@@ -213,6 +219,15 @@ public class DictionaryRecordService {
|
||||
private void validate(DictionaryDefinition d, CreateRecordRequest req) {
|
||||
ValidationResult vr = validator.validate(d, req.data());
|
||||
if (!vr.valid()) throw new ValidationException(vr.errors());
|
||||
|
||||
// Cross-dictionary x-references — после schema validation, потому что
|
||||
// если schema fail, value может быть not-textual и refs check бросит false-positives.
|
||||
var refErrors = referenceValidator.validate(
|
||||
d.getSchemaJson(),
|
||||
req.data(),
|
||||
scopeContext.currentScopes().stream().toList(),
|
||||
OffsetDateTime.now());
|
||||
if (!refErrors.isEmpty()) throw new ValidationException(refErrors);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
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.DictionaryRecordRepository;
|
||||
import cloud.nstart.terravault.ordinis.validation.ValidationError;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Cross-dictionary FK references — v2 feature. JSON Schema extension
|
||||
* {@code x-references: "dict_name.field_name"} объявляет что значение
|
||||
* в этом поле должно ссылаться на active record в указанном словаре,
|
||||
* где {@code data->>field_name} == referencing value.
|
||||
*
|
||||
* <p>Пример:
|
||||
* <pre>
|
||||
* {
|
||||
* "type": "object",
|
||||
* "properties": {
|
||||
* "satelliteTypeCode": {
|
||||
* "type": "string",
|
||||
* "x-references": "satellite_type.code"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* </pre>
|
||||
*
|
||||
* <p>На POST/PUT record service вызывает {@link #validate} который для
|
||||
* каждого x-references поля делает lookup. Если referenced record не
|
||||
* найден / не активен / не в доступном scope — ValidationError.
|
||||
*
|
||||
* <p>v1 ограничения:
|
||||
* <ul>
|
||||
* <li>Только top-level fields (nested object refs не поддержаны).</li>
|
||||
* <li>Только string-valued references (number/array — позже).</li>
|
||||
* <li>Без cascade на delete/close: если referenced record закрывается,
|
||||
* referencing records остаются с dangling FK. Отдельный alert
|
||||
* будет в v2.5.</li>
|
||||
* <li>Валидация в момент CRUD, не на schema change. Если меняется
|
||||
* schema с new x-references на existing data — может быть orphan,
|
||||
* проверяем при first re-save.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
public class ReferenceValidator {
|
||||
|
||||
private final DictionaryDefinitionRepository definitionRepository;
|
||||
private final DictionaryRecordRepository recordRepository;
|
||||
|
||||
public ReferenceValidator(
|
||||
DictionaryDefinitionRepository definitionRepository,
|
||||
DictionaryRecordRepository recordRepository) {
|
||||
this.definitionRepository = definitionRepository;
|
||||
this.recordRepository = recordRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate references. Возвращает список errors (пусто = OK). Не бросает.
|
||||
* Caller (DictionaryRecordService) кидает ValidationException если errors > 0.
|
||||
*
|
||||
* @param schema JSON Schema справочника (содержит x-references markers)
|
||||
* @param data payload записи
|
||||
* @param allowedScopes scope подмножество которое viewер видит — referenced
|
||||
* records должны быть в нём (если выше — denied)
|
||||
* @param at момент времени (transaction time) — обычно now()
|
||||
*/
|
||||
public List<ValidationError> validate(
|
||||
JsonNode schema, JsonNode data, List<DataScope> allowedScopes, OffsetDateTime at) {
|
||||
List<ValidationError> errors = new ArrayList<>();
|
||||
if (schema == null || data == null || !schema.isObject() || !data.isObject()) {
|
||||
return errors;
|
||||
}
|
||||
JsonNode properties = schema.get("properties");
|
||||
if (properties == null || !properties.isObject()) return errors;
|
||||
|
||||
OffsetDateTime when = at == null ? OffsetDateTime.now() : at;
|
||||
|
||||
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;
|
||||
|
||||
String refSpec = refNode.asText();
|
||||
ParsedRef parsed;
|
||||
try {
|
||||
parsed = parseRef(refSpec);
|
||||
} catch (IllegalArgumentException e) {
|
||||
errors.add(new ValidationError(
|
||||
"/" + fieldName,
|
||||
"x_references_malformed",
|
||||
"x-references value malformed: " + e.getMessage()));
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonNode value = data.get(fieldName);
|
||||
if (value == null || value.isNull()) {
|
||||
// Если поле required, JSON Schema валидация уже отлавливает.
|
||||
// Если optional — references skip (нет значения = нет проверки).
|
||||
continue;
|
||||
}
|
||||
if (!value.isTextual()) {
|
||||
errors.add(new ValidationError(
|
||||
"/" + fieldName,
|
||||
"x_references_non_string",
|
||||
"x-references поддерживает только string values в v1, got: " + value.getNodeType()));
|
||||
continue;
|
||||
}
|
||||
String stringValue = value.asText();
|
||||
|
||||
Optional<DictionaryDefinition> targetOpt =
|
||||
definitionRepository.findByName(parsed.dictionaryName());
|
||||
if (targetOpt.isEmpty()) {
|
||||
errors.add(new ValidationError(
|
||||
"/" + fieldName,
|
||||
"x_references_dict_not_found",
|
||||
"Referenced dictionary '" + parsed.dictionaryName() + "' does not exist"));
|
||||
continue;
|
||||
}
|
||||
DictionaryDefinition target = targetOpt.get();
|
||||
|
||||
if (!allowedScopes.contains(target.getScope())) {
|
||||
// Scope hide: referenced dictionary not visible to caller. Возвращаем
|
||||
// ту же ошибку что и "not found" чтобы не leak'ало existence.
|
||||
errors.add(new ValidationError(
|
||||
"/" + fieldName,
|
||||
"x_references_target_not_found",
|
||||
"Referenced record not found in '" + parsed.dictionaryName()
|
||||
+ "' for value '" + stringValue + "'"));
|
||||
continue;
|
||||
}
|
||||
|
||||
List<?> matches = recordRepository.findActiveByJsonField(
|
||||
target.getId(), when, parsed.fieldName(), stringValue);
|
||||
|
||||
if (matches.isEmpty()) {
|
||||
errors.add(new ValidationError(
|
||||
"/" + fieldName,
|
||||
"x_references_target_not_found",
|
||||
"Referenced record not found in '" + parsed.dictionaryName()
|
||||
+ "' for " + parsed.fieldName() + "='" + stringValue + "'"));
|
||||
}
|
||||
// Если matches.size() > 1 — это ситуация когда referenced field не
|
||||
// x-unique. Проходим: matches.first работает. Лог-warning отдельно
|
||||
// (TODO: emit metric nsi_reference_ambiguous_total).
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse {@code "dict_name.field"} format. Validates что обе части
|
||||
* непустые и не содержат точек (чтобы избежать unexpected nested paths).
|
||||
*/
|
||||
static ParsedRef parseRef(String spec) {
|
||||
if (spec == null || spec.isBlank()) {
|
||||
throw new IllegalArgumentException("empty x-references");
|
||||
}
|
||||
int dot = spec.indexOf('.');
|
||||
if (dot <= 0 || dot == spec.length() - 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"x-references must be 'dict_name.field', got: " + spec);
|
||||
}
|
||||
String dict = spec.substring(0, dot);
|
||||
String field = spec.substring(dot + 1);
|
||||
if (dict.contains(".") || field.contains(".")) {
|
||||
throw new IllegalArgumentException(
|
||||
"x-references nested paths not supported in v1: " + spec);
|
||||
}
|
||||
return new ParsedRef(dict, field);
|
||||
}
|
||||
|
||||
public record ParsedRef(String dictionaryName, String fieldName) {}
|
||||
}
|
||||
+1
-1
@@ -38,7 +38,7 @@ class DictionaryRecordServiceTest {
|
||||
repo = mock(DictionaryRecordRepository.class);
|
||||
// resolveBusinessKey/enforceUniqueFields не дёргают остальные деп — передаём null,
|
||||
// mocking concrete Spring services через Mockito-inline на JDK 25 ломается.
|
||||
service = new DictionaryRecordService(repo, null, null, null, null);
|
||||
service = new DictionaryRecordService(repo, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
// ============= resolveBusinessKey =============
|
||||
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
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.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
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;
|
||||
|
||||
class ReferenceValidatorTest {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// === parseRef static tests ===
|
||||
|
||||
@Test
|
||||
void parseRefAcceptsValid() {
|
||||
var p = ReferenceValidator.parseRef("satellite_type.code");
|
||||
assertThat(p.dictionaryName()).isEqualTo("satellite_type");
|
||||
assertThat(p.fieldName()).isEqualTo("code");
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseRefRejectsEmpty() {
|
||||
assertThatThrownBy(() -> ReferenceValidator.parseRef(""))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> ReferenceValidator.parseRef(null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseRefRejectsMissingDot() {
|
||||
assertThatThrownBy(() -> ReferenceValidator.parseRef("noDot"))
|
||||
.hasMessageContaining("dict_name.field");
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseRefRejectsTrailingDot() {
|
||||
assertThatThrownBy(() -> ReferenceValidator.parseRef("dict."))
|
||||
.hasMessageContaining("dict_name.field");
|
||||
assertThatThrownBy(() -> ReferenceValidator.parseRef(".field"))
|
||||
.hasMessageContaining("dict_name.field");
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseRefRejectsNestedPaths() {
|
||||
assertThatThrownBy(() -> ReferenceValidator.parseRef("a.b.c"))
|
||||
.hasMessageContaining("nested paths not supported");
|
||||
}
|
||||
|
||||
// === validate() tests ===
|
||||
|
||||
@Test
|
||||
void schemaWithoutReferences_noErrors() throws Exception {
|
||||
JsonNode schema = OM.readTree("""
|
||||
{"type":"object","properties":{"name":{"type":"string"}}}""");
|
||||
JsonNode data = OM.readTree("{\"name\":\"foo\"}");
|
||||
|
||||
var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||
assertThat(errors).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingValue_skippedSilently() throws Exception {
|
||||
JsonNode schema = OM.readTree("""
|
||||
{"type":"object","properties":{
|
||||
"ref":{"type":"string","x-references":"satellite_type.code"}
|
||||
}}""");
|
||||
JsonNode data = OM.readTree("{\"name\":\"foo\"}"); // нет 'ref'
|
||||
|
||||
var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||
assertThat(errors).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingReferencedRecord_noErrors() throws Exception {
|
||||
UUID targetId = UUID.randomUUID();
|
||||
DictionaryDefinition target = mockDefinition("satellite_type", DataScope.PUBLIC, targetId);
|
||||
when(defRepo.findByName("satellite_type")).thenReturn(Optional.of(target));
|
||||
when(recRepo.findActiveByJsonField(eq(targetId), any(), eq("code"), eq("LEO-001")))
|
||||
.thenReturn(List.of(stubRecord()));
|
||||
|
||||
JsonNode schema = OM.readTree("""
|
||||
{"type":"object","properties":{
|
||||
"satTypeCode":{"type":"string","x-references":"satellite_type.code"}
|
||||
}}""");
|
||||
JsonNode data = OM.readTree("{\"satTypeCode\":\"LEO-001\"}");
|
||||
|
||||
var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||
assertThat(errors).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingReferencedRecord_returnsError() throws Exception {
|
||||
UUID targetId = UUID.randomUUID();
|
||||
DictionaryDefinition target = mockDefinition("satellite_type", DataScope.PUBLIC, targetId);
|
||||
when(defRepo.findByName("satellite_type")).thenReturn(Optional.of(target));
|
||||
when(recRepo.findActiveByJsonField(any(), any(), anyString(), anyString()))
|
||||
.thenReturn(List.of()); // no matches
|
||||
|
||||
JsonNode schema = OM.readTree("""
|
||||
{"type":"object","properties":{
|
||||
"satTypeCode":{"type":"string","x-references":"satellite_type.code"}
|
||||
}}""");
|
||||
JsonNode data = OM.readTree("{\"satTypeCode\":\"NONEXISTENT\"}");
|
||||
|
||||
var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||
assertThat(errors).hasSize(1);
|
||||
assertThat(errors.get(0).code()).isEqualTo("x_references_target_not_found");
|
||||
assertThat(errors.get(0).message()).contains("satellite_type");
|
||||
assertThat(errors.get(0).message()).contains("NONEXISTENT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingReferencedDictionary_returnsError() throws Exception {
|
||||
when(defRepo.findByName("nonexistent_dict")).thenReturn(Optional.empty());
|
||||
|
||||
JsonNode schema = OM.readTree("""
|
||||
{"type":"object","properties":{
|
||||
"ref":{"type":"string","x-references":"nonexistent_dict.code"}
|
||||
}}""");
|
||||
JsonNode data = OM.readTree("{\"ref\":\"any\"}");
|
||||
|
||||
var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||
assertThat(errors).hasSize(1);
|
||||
assertThat(errors.get(0).code()).isEqualTo("x_references_dict_not_found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void targetScopeNotAccessible_returnsNotFoundError() throws Exception {
|
||||
// Caller имеет PUBLIC scope, target dict — INTERNAL.
|
||||
// Должен вернуть not_found (не "scope_denied") чтобы не leak existence.
|
||||
UUID targetId = UUID.randomUUID();
|
||||
DictionaryDefinition target = mockDefinition("internal_dict", DataScope.INTERNAL, targetId);
|
||||
when(defRepo.findByName("internal_dict")).thenReturn(Optional.of(target));
|
||||
|
||||
JsonNode schema = OM.readTree("""
|
||||
{"type":"object","properties":{
|
||||
"ref":{"type":"string","x-references":"internal_dict.code"}
|
||||
}}""");
|
||||
JsonNode data = OM.readTree("{\"ref\":\"X\"}");
|
||||
|
||||
var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||
assertThat(errors).hasSize(1);
|
||||
assertThat(errors.get(0).code()).isEqualTo("x_references_target_not_found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonStringValue_returnsError() throws Exception {
|
||||
JsonNode schema = OM.readTree("""
|
||||
{"type":"object","properties":{
|
||||
"ref":{"type":"integer","x-references":"d.f"}
|
||||
}}""");
|
||||
JsonNode data = OM.readTree("{\"ref\":42}");
|
||||
|
||||
var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||
assertThat(errors).hasSize(1);
|
||||
assertThat(errors.get(0).code()).isEqualTo("x_references_non_string");
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedRefSpec_returnsError() throws Exception {
|
||||
JsonNode schema = OM.readTree("""
|
||||
{"type":"object","properties":{
|
||||
"ref":{"type":"string","x-references":"malformed-no-dot"}
|
||||
}}""");
|
||||
JsonNode data = OM.readTree("{\"ref\":\"any\"}");
|
||||
|
||||
var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||
assertThat(errors).hasSize(1);
|
||||
assertThat(errors.get(0).code()).isEqualTo("x_references_malformed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipleReferenceFields_validatedIndependently() throws Exception {
|
||||
UUID dictAId = UUID.randomUUID();
|
||||
UUID dictBId = UUID.randomUUID();
|
||||
when(defRepo.findByName("dict_a")).thenReturn(
|
||||
Optional.of(mockDefinition("dict_a", DataScope.PUBLIC, dictAId)));
|
||||
when(defRepo.findByName("dict_b")).thenReturn(
|
||||
Optional.of(mockDefinition("dict_b", DataScope.PUBLIC, dictBId)));
|
||||
// Только в dict_a есть match. dict_b — пусто.
|
||||
when(recRepo.findActiveByJsonField(eq(dictAId), any(), eq("c"), eq("X")))
|
||||
.thenReturn(List.of(stubRecord()));
|
||||
when(recRepo.findActiveByJsonField(eq(dictBId), any(), any(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
JsonNode schema = OM.readTree("""
|
||||
{"type":"object","properties":{
|
||||
"refA":{"type":"string","x-references":"dict_a.c"},
|
||||
"refB":{"type":"string","x-references":"dict_b.c"}
|
||||
}}""");
|
||||
JsonNode data = OM.readTree("{\"refA\":\"X\",\"refB\":\"Y\"}");
|
||||
|
||||
var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||
// refA OK, refB fails → 1 error
|
||||
assertThat(errors).hasSize(1);
|
||||
assertThat(errors.get(0).path()).isEqualTo("/refB");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullSchema_returnsEmptyErrors() {
|
||||
var errors = validator.validate(null, null, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||
assertThat(errors).isEmpty();
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
/**
|
||||
* Mockito не может mock'нуть DictionaryDefinition на JDK 25 (см. memo
|
||||
* "JDK 25 + Mockito subclass mock-maker incompat"). Используем real
|
||||
* entity — у неё public ctor с минимальными полями.
|
||||
*/
|
||||
private static DictionaryDefinition mockDefinition(String name, DataScope scope, UUID id) {
|
||||
return new DictionaryDefinition(id, name, scope, null);
|
||||
}
|
||||
|
||||
/** Аналогично — real DictionaryRecord vместо mock. Validator только
|
||||
* проверяет {@code isEmpty()}, fields этого record нам не важны. */
|
||||
private static DictionaryRecord stubRecord() {
|
||||
return new DictionaryRecord(
|
||||
UUID.randomUUID(), UUID.randomUUID(), "stub-key",
|
||||
OM.createObjectNode(), DataScope.PUBLIC, OffsetDateTime.now());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user