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:
+144
@@ -0,0 +1,144 @@
|
||||
package cloud.nstart.terravault.ordinis.auth;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.core.authority.AuthorityUtils;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ScopeContextTest {
|
||||
|
||||
private final OrdinisAuthProperties propsWithQuery =
|
||||
new OrdinisAuthProperties(null, null, null, "realm_access.roles", false, true);
|
||||
private final OrdinisAuthProperties propsNoQuery =
|
||||
new OrdinisAuthProperties(null, null, null, "realm_access.roles", false, false);
|
||||
|
||||
@AfterEach
|
||||
void clearContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
// ============= role → scope mapping =============
|
||||
|
||||
@Test
|
||||
void mapsOrdinisDashRolesToScopes() {
|
||||
setJwtWithRealmRoles(List.of("ordinis-public", "ordinis-internal"));
|
||||
assertThat(new ScopeContext(propsNoQuery).currentScopes())
|
||||
.containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsUnderscoreUppercaseRolesToScopes() {
|
||||
setJwtWithRealmRoles(List.of("ORDINIS_RESTRICTED"));
|
||||
assertThat(new ScopeContext(propsNoQuery).currentScopes())
|
||||
.containsExactly(DataScope.RESTRICTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsBareScopeNamesToScopes() {
|
||||
setJwtWithRealmRoles(List.of("PUBLIC", "INTERNAL", "RESTRICTED"));
|
||||
assertThat(new ScopeContext(propsNoQuery).currentScopes())
|
||||
.containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsArbitrarySuffixedRoles() {
|
||||
setJwtWithRealmRoles(List.of("any-prefix-PUBLIC", "x_INTERNAL"));
|
||||
assertThat(new ScopeContext(propsNoQuery).currentScopes())
|
||||
.containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unrecognizedRolesGivePublicFallback() {
|
||||
setJwtWithRealmRoles(List.of("admin", "viewer"));
|
||||
assertThat(new ScopeContext(propsNoQuery).currentScopes())
|
||||
.containsExactly(DataScope.PUBLIC);
|
||||
}
|
||||
|
||||
// ============= JWT vs query =============
|
||||
|
||||
@Test
|
||||
void anonymousNoQueryReturnsPublic() {
|
||||
setAnonymous();
|
||||
assertThat(new ScopeContext(propsNoQuery).resolveForRead(null))
|
||||
.containsExactly(DataScope.PUBLIC);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousQueryAllowedReturnsRequestedScopes() {
|
||||
setAnonymous();
|
||||
assertThat(new ScopeContext(propsWithQuery).resolveForRead("internal,restricted"))
|
||||
.containsExactlyInAnyOrder(DataScope.INTERNAL, DataScope.RESTRICTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousQueryDisallowedAlwaysReturnsPublic() {
|
||||
setAnonymous();
|
||||
assertThat(new ScopeContext(propsNoQuery).resolveForRead("internal,restricted"))
|
||||
.containsExactly(DataScope.PUBLIC);
|
||||
}
|
||||
|
||||
@Test
|
||||
void jwtAlwaysWinsOverQueryParam() {
|
||||
setJwtWithRealmRoles(List.of("ordinis-public"));
|
||||
Set<DataScope> scopes = new ScopeContext(propsWithQuery).resolveForRead("RESTRICTED");
|
||||
assertThat(scopes).containsExactly(DataScope.PUBLIC);
|
||||
}
|
||||
|
||||
// ============= claim path resolution =============
|
||||
|
||||
@Test
|
||||
void supportsCustomClaimPath() {
|
||||
var props = new OrdinisAuthProperties(null, null, null, "scopes", false, false);
|
||||
setJwtWithFlatScopes(List.of("PUBLIC", "RESTRICTED"));
|
||||
assertThat(new ScopeContext(props).currentScopes())
|
||||
.containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.RESTRICTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyClaimReturnsPublic() {
|
||||
setJwtWithRealmRoles(List.of());
|
||||
assertThat(new ScopeContext(propsNoQuery).currentScopes())
|
||||
.containsExactly(DataScope.PUBLIC);
|
||||
}
|
||||
|
||||
// ============= helpers =============
|
||||
|
||||
private void setJwtWithRealmRoles(List<String> roles) {
|
||||
Jwt jwt = Jwt.withTokenValue("dummy")
|
||||
.header("alg", "RS256")
|
||||
.issuedAt(Instant.now())
|
||||
.expiresAt(Instant.now().plusSeconds(3600))
|
||||
.claim("realm_access", Map.of("roles", roles))
|
||||
.build();
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(new JwtAuthenticationToken(jwt, AuthorityUtils.NO_AUTHORITIES));
|
||||
}
|
||||
|
||||
private void setJwtWithFlatScopes(List<String> scopes) {
|
||||
Jwt jwt = Jwt.withTokenValue("dummy")
|
||||
.header("alg", "RS256")
|
||||
.issuedAt(Instant.now())
|
||||
.expiresAt(Instant.now().plusSeconds(3600))
|
||||
.claim("scopes", scopes)
|
||||
.build();
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(new JwtAuthenticationToken(jwt, AuthorityUtils.NO_AUTHORITIES));
|
||||
}
|
||||
|
||||
private void setAnonymous() {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new AnonymousAuthenticationToken("anon", "anon",
|
||||
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")));
|
||||
}
|
||||
}
|
||||
+46
-4
@@ -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,
|
||||
|
||||
+143
@@ -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);
|
||||
}
|
||||
}
|
||||
+195
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,16 @@
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<!-- Тестовые зависимости — common для всех модулей.
|
||||
spring-boot-starter-test тянет JUnit 5, Mockito, AssertJ, Hamcrest. -->
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<pluginManagement>
|
||||
<plugins>
|
||||
|
||||
Reference in New Issue
Block a user