test: unit-тесты для ScopeContext, IdempotencyService, DictionaryRecordService + fix exclusion 500 → 409

35 unit-тестов зелёных:

ordinis-auth (11): ScopeContextTest
- Маппинг ролей: ordinis-public/ORDINIS_INTERNAL/x-RESTRICTED/PUBLIC
- Nested claim path realm_access.roles + кастомный путь scopes
- Anonymous + query allowed/disallowed
- JWT всегда побеждает query as_scope (escalation defense)
- Unrecognized roles → PUBLIC fallback

ordinis-rest-api/idempotency (13): IdempotencyServiceTest
- hashRequest: deterministic, sha-256 hex 64 chars, null=empty
- lookup: empty/match/422 на mismatch
- reserve: saveAndFlush, race с тем же hash → 409 InProgress, race с разным hash → 422 Conflict
- saveResponse: skip missing, persist parsed JSON, gracefully игнорит non-JSON

ordinis-rest-api/service (11): DictionaryRecordServiceTest
- resolveBusinessKey: explicit / no-source missing key throws / x-id-source derive /
  match accepted / mismatch throws / missing source field throws
- enforceUniqueFields: no-unique no-op / no-conflict / conflict 409 /
  excludes own record on update / skips null values

Hotfix backend: exclusion constraint 500 → friendly 409
- DictionaryRecordService.create() / update() ловят DataIntegrityViolationException,
  если SQLState 23P01 или message содержит dictionary_records_no_overlap →
  OrdinisException.conflict('record_period_overlap', ...) с понятным сообщением
  про пересекающийся диапазон.
- Saved → saveAndFlush: get DB error eagerly чтобы поймать его в catch.

Test setup:
- spring-boot-starter-test (test scope) в parent pom.
- Mockito mock для DictionaryDefinitionService на JDK 25 ломается, передаём null
  (тестируемые методы не дёргают этот dependency).
This commit is contained in:
Zimin A.N.
2026-05-04 04:07:33 +03:00
parent 07c8a19cf3
commit 416c44bd94
5 changed files with 538 additions and 4 deletions
@@ -16,6 +16,7 @@ import cloud.nstart.terravault.ordinis.validation.ValidationResult;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.ParseException;
import org.locationtech.jts.io.WKTReader;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
@@ -97,7 +98,18 @@ public class DictionaryRecordService {
record.setValidTo(validTo);
record.setGeometry(parseWkt(req.geometryWkt()));
var saved = repository.save(record);
DictionaryRecord saved;
try {
saved = repository.saveAndFlush(record);
} catch (DataIntegrityViolationException ex) {
if (isExclusionViolation(ex)) {
throw OrdinisException.conflict(
"record_period_overlap",
"Период [" + validFrom + ", " + validTo + ") пересекается с существующей версией business_key='"
+ businessKey + "'. Закройте старую версию или скорректируйте даты.");
}
throw ex;
}
publishCreated(d, saved);
return saved;
}
@@ -135,7 +147,19 @@ public class DictionaryRecordService {
newValidFrom);
next.setValidTo(newValidTo);
next.setGeometry(parseWkt(req.geometryWkt()));
var saved = repository.save(next);
DictionaryRecord saved;
try {
saved = repository.saveAndFlush(next);
} catch (DataIntegrityViolationException ex) {
if (isExclusionViolation(ex)) {
throw OrdinisException.conflict(
"record_period_overlap",
"Новый период [" + newValidFrom + ", " + newValidTo
+ ") пересекается с другой исторической версией business_key='"
+ businessKey + "'. Скорректируйте даты.");
}
throw ex;
}
publishUpdated(d, saved, previousRecordId, prevData);
return saved;
@@ -159,6 +183,24 @@ public class DictionaryRecordService {
// — internals —
/**
* Распознаёт PG exclusion constraint violation в любом нестоящем месте цепочки причин.
* SQLState 23P01 = exclusion_violation.
*/
private static boolean isExclusionViolation(Throwable ex) {
Throwable t = ex;
while (t != null) {
String msg = t.getMessage();
if (msg != null && (msg.contains("23P01")
|| msg.contains("dictionary_records_no_overlap")
|| msg.contains("exclusion constraint"))) {
return true;
}
t = t.getCause();
}
return false;
}
private void validate(DictionaryDefinition d, CreateRecordRequest req) {
ValidationResult vr = validator.validate(d, req.data());
if (!vr.valid()) throw new ValidationException(vr.errors());
@@ -169,7 +211,7 @@ public class DictionaryRecordService {
* берётся из {@code data[field]}. Если клиент явно прислал businessKey — он
* должен совпадать, иначе 422 (защита от рассинхронизации).
*/
private String resolveBusinessKey(DictionaryDefinition d, CreateRecordRequest req) {
String resolveBusinessKey(DictionaryDefinition d, CreateRecordRequest req) {
var schema = d.getSchemaJson();
var idSourceNode = schema == null ? null : schema.get("x-id-source");
if (idSourceNode == null || !idSourceNode.isTextual() || idSourceNode.asText().isBlank()) {
@@ -203,7 +245,7 @@ public class DictionaryRecordService {
* (на момент {@code at}) с тем же значением. Исключает текущую версию (для
* update). Бьёт 409 при дубликате.
*/
private void enforceUniqueFields(
void enforceUniqueFields(
DictionaryDefinition d,
com.fasterxml.jackson.databind.JsonNode data,
OffsetDateTime at,
@@ -0,0 +1,143 @@
package cloud.nstart.terravault.ordinis.restapi.idempotency;
import cloud.nstart.terravault.ordinis.domain.idempotency.IdempotencyKey;
import cloud.nstart.terravault.ordinis.domain.idempotency.IdempotencyKeyRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import java.time.OffsetDateTime;
import java.util.Optional;
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.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class IdempotencyServiceTest {
private IdempotencyKeyRepository repo;
private IdempotencyService service;
@BeforeEach
void setUp() {
repo = mock(IdempotencyKeyRepository.class);
service = new IdempotencyService(repo, new ObjectMapper());
}
// ============= hashRequest =============
@Test
void hashIsDeterministicForSameInput() {
byte[] body = "{\"a\":1}".getBytes();
assertThat(service.hashRequest(body)).isEqualTo(service.hashRequest(body));
}
@Test
void hashDiffersForDifferentInput() {
assertThat(service.hashRequest("{\"a\":1}".getBytes()))
.isNotEqualTo(service.hashRequest("{\"a\":2}".getBytes()));
}
@Test
void hashHandlesNullAsEmpty() {
assertThat(service.hashRequest(null)).isEqualTo(service.hashRequest(new byte[0]));
}
@Test
void hashIsSha256Hex() {
String hash = service.hashRequest("hello".getBytes());
assertThat(hash).hasSize(64).matches("[0-9a-f]{64}");
}
// ============= lookup =============
@Test
void lookupReturnsEmptyWhenKeyMissing() {
when(repo.findById("k1")).thenReturn(Optional.empty());
assertThat(service.lookup("k1", "hash")).isEmpty();
}
@Test
void lookupReturnsEntryWhenHashMatches() {
var key = new IdempotencyKey("k1", "hash-A", OffsetDateTime.now());
when(repo.findById("k1")).thenReturn(Optional.of(key));
assertThat(service.lookup("k1", "hash-A")).contains(key);
}
@Test
void lookupThrows422OnHashMismatch() {
var key = new IdempotencyKey("k1", "hash-A", OffsetDateTime.now());
when(repo.findById("k1")).thenReturn(Optional.of(key));
assertThatThrownBy(() -> service.lookup("k1", "hash-B"))
.isInstanceOf(IdempotencyConflictException.class);
}
// ============= reserve =============
@Test
void reserveCallsSaveAndFlush() {
service.reserve("k1", "hash-A");
verify(repo, times(1)).saveAndFlush(any(IdempotencyKey.class));
}
@Test
void reserveOnRaceWithSameHashThrowsInProgress() {
var existing = new IdempotencyKey("k1", "hash-A", OffsetDateTime.now());
when(repo.saveAndFlush(any(IdempotencyKey.class)))
.thenThrow(new DataIntegrityViolationException("dup"));
when(repo.findById("k1")).thenReturn(Optional.of(existing));
assertThatThrownBy(() -> service.reserve("k1", "hash-A"))
.isInstanceOf(IdempotencyInProgressException.class);
}
@Test
void reserveOnRaceWithDifferentHashThrowsConflict() {
var existing = new IdempotencyKey("k1", "hash-other", OffsetDateTime.now());
when(repo.saveAndFlush(any(IdempotencyKey.class)))
.thenThrow(new DataIntegrityViolationException("dup"));
when(repo.findById("k1")).thenReturn(Optional.of(existing));
assertThatThrownBy(() -> service.reserve("k1", "hash-A"))
.isInstanceOf(IdempotencyConflictException.class);
}
// ============= saveResponse =============
@Test
void saveResponseSkipsMissingKey() {
when(repo.findById("absent")).thenReturn(Optional.empty());
service.saveResponse("absent", 200, "{}".getBytes());
verify(repo, times(0)).save(any(IdempotencyKey.class));
}
@Test
void saveResponsePersistsParsedBody() {
var key = new IdempotencyKey("k1", "hash", OffsetDateTime.now());
when(repo.findById("k1")).thenReturn(Optional.of(key));
service.saveResponse("k1", 201, "{\"id\":\"abc\"}".getBytes());
assertThat(key.getResponseStatus()).isEqualTo(201);
assertThat(key.getResponseBody()).isNotNull();
assertThat(key.getResponseBody().get("id").asText()).isEqualTo("abc");
verify(repo).save(key);
}
@Test
void saveResponseHandlesNonJsonGracefully() {
var key = new IdempotencyKey("k1", "hash", OffsetDateTime.now());
when(repo.findById("k1")).thenReturn(Optional.of(key));
service.saveResponse("k1", 500, "not-json".getBytes());
assertThat(key.getResponseStatus()).isEqualTo(500);
assertThat(key.getResponseBody()).isNull();
verify(repo).save(key);
}
}
@@ -0,0 +1,195 @@
package cloud.nstart.terravault.ordinis.restapi.service;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.validation.RecordValidator;
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.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 DictionaryRecordServiceTest {
private final ObjectMapper om = new ObjectMapper();
private DictionaryRecordRepository repo;
private DictionaryRecordService service;
private static final UUID DICT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
@BeforeEach
void setUp() {
repo = mock(DictionaryRecordRepository.class);
// resolveBusinessKey/enforceUniqueFields не дёргают остальные деп — передаём null,
// mocking concrete Spring services через Mockito-inline на JDK 25 ломается.
service = new DictionaryRecordService(repo, null, null, null);
}
// ============= resolveBusinessKey =============
@Test
void resolveReturnsExplicitKeyWhenNoIdSource() throws Exception {
var d = definitionWithSchema("""
{ "type":"object", "properties": {} }
""");
var req = new CreateRecordRequest("MY_KEY", om.readTree("{}"), null, null, null);
assertThat(service.resolveBusinessKey(d, req)).isEqualTo("MY_KEY");
}
@Test
void resolveThrowsWhenNoIdSourceAndNoExplicitKey() throws Exception {
var d = definitionWithSchema("""
{ "type":"object", "properties": {} }
""");
var req = new CreateRecordRequest(null, om.readTree("{}"), null, null, null);
assertThatThrownBy(() -> service.resolveBusinessKey(d, req))
.isInstanceOfSatisfying(OrdinisException.class,
ex -> assertThat(ex.getCode()).isEqualTo("business_key_required"));
}
@Test
void resolveDerivesKeyFromIdSourceField() throws Exception {
var d = definitionWithSchema("""
{ "type":"object", "x-id-source":"code",
"properties": {"code":{"type":"string"}} }
""");
var req = new CreateRecordRequest(null, om.readTree("{\"code\":\"AUTO_KEY\"}"), null, null, null);
assertThat(service.resolveBusinessKey(d, req)).isEqualTo("AUTO_KEY");
}
@Test
void resolveAcceptsExplicitKeyWhenItMatchesIdSource() throws Exception {
var d = definitionWithSchema("""
{ "type":"object", "x-id-source":"code",
"properties": {"code":{"type":"string"}} }
""");
var req = new CreateRecordRequest("AUTO_KEY", om.readTree("{\"code\":\"AUTO_KEY\"}"), null, null, null);
assertThat(service.resolveBusinessKey(d, req)).isEqualTo("AUTO_KEY");
}
@Test
void resolveThrowsOnMismatchBetweenExplicitAndDerived() throws Exception {
var d = definitionWithSchema("""
{ "type":"object", "x-id-source":"code",
"properties": {"code":{"type":"string"}} }
""");
var req = new CreateRecordRequest("WRONG", om.readTree("{\"code\":\"AUTO_KEY\"}"), null, null, null);
assertThatThrownBy(() -> service.resolveBusinessKey(d, req))
.isInstanceOfSatisfying(OrdinisException.class,
ex -> assertThat(ex.getCode()).isEqualTo("business_key_mismatch"));
}
@Test
void resolveThrowsWhenIdSourceFieldMissingInData() throws Exception {
var d = definitionWithSchema("""
{ "type":"object", "x-id-source":"code",
"properties": {"code":{"type":"string"}} }
""");
var req = new CreateRecordRequest(null, om.readTree("{}"), null, null, null);
assertThatThrownBy(() -> service.resolveBusinessKey(d, req))
.isInstanceOfSatisfying(OrdinisException.class,
ex -> assertThat(ex.getCode()).isEqualTo("id_source_missing"));
}
// ============= enforceUniqueFields =============
@Test
void enforceNoUniqueFieldsIsNoOp() throws Exception {
var d = definitionWithSchema("""
{ "type":"object",
"properties": {"code":{"type":"string"}} }
""");
JsonNode data = om.readTree("{\"code\":\"X\"}");
service.enforceUniqueFields(d, data, OffsetDateTime.now(), null);
// нет вызовов в repo — just no exception
}
@Test
void enforceUniqueFieldNoConflict() throws Exception {
var d = definitionWithSchema("""
{ "type":"object",
"properties": {"code":{"type":"string","x-unique":true}} }
""");
when(repo.findActiveByJsonField(eq(DICT_ID), any(), eq("code"), eq("X")))
.thenReturn(List.of());
JsonNode data = om.readTree("{\"code\":\"X\"}");
service.enforceUniqueFields(d, data, OffsetDateTime.now(), null);
}
@Test
void enforceUniqueFieldConflictThrows409() throws Exception {
var d = definitionWithSchema("""
{ "type":"object",
"properties": {"code":{"type":"string","x-unique":true}} }
""");
var conflict = new DictionaryRecord(
UUID.randomUUID(), DICT_ID, "OTHER",
om.readTree("{\"code\":\"X\"}"), DataScope.PUBLIC, OffsetDateTime.now());
when(repo.findActiveByJsonField(eq(DICT_ID), any(), eq("code"), eq("X")))
.thenReturn(List.of(conflict));
JsonNode data = om.readTree("{\"code\":\"X\"}");
assertThatThrownBy(() -> service.enforceUniqueFields(d, data, OffsetDateTime.now(), null))
.isInstanceOfSatisfying(OrdinisException.class,
ex -> assertThat(ex.getCode()).isEqualTo("unique_field_violation"));
}
@Test
void enforceUniqueFieldExcludesOwnRecordOnUpdate() throws Exception {
var d = definitionWithSchema("""
{ "type":"object",
"properties": {"code":{"type":"string","x-unique":true}} }
""");
UUID ownId = UUID.randomUUID();
var own = new DictionaryRecord(
ownId, DICT_ID, "MY_KEY",
om.readTree("{\"code\":\"X\"}"), DataScope.PUBLIC, OffsetDateTime.now());
when(repo.findActiveByJsonField(eq(DICT_ID), any(), eq("code"), eq("X")))
.thenReturn(List.of(own));
JsonNode data = om.readTree("{\"code\":\"X\"}");
// Не должно бросать — собственная запись исключается из проверки
service.enforceUniqueFields(d, data, OffsetDateTime.now(), ownId);
}
@Test
void enforceSkipsNullValueFields() throws Exception {
var d = definitionWithSchema("""
{ "type":"object",
"properties": {
"code":{"type":"string","x-unique":true},
"alias":{"type":"string","x-unique":true}
}
}
""");
when(repo.findActiveByJsonField(eq(DICT_ID), any(), anyString(), anyString()))
.thenReturn(List.of());
JsonNode data = om.readTree("{\"code\":\"X\"}"); // alias absent
service.enforceUniqueFields(d, data, OffsetDateTime.now(), null);
// Repository вызывается только для code, не для alias — проверка проходит
}
// ============= helpers =============
private DictionaryDefinition definitionWithSchema(String schemaJson) throws Exception {
return new DictionaryDefinition(DICT_ID, "test_dict", DataScope.PUBLIC, om.readTree(schemaJson));
}
}