Merge branch 'feat/schema-patch-inline' into 'main'

feat(api): PATCH /dictionaries/{name}/schema — inline non-breaking field edit (backend #4)

See merge request 2-6/2-6-4/terravault/ordinis!98
This commit is contained in:
Александр Зимин
2026-05-11 20:26:43 +00:00
9 changed files with 816 additions and 11 deletions
@@ -146,4 +146,36 @@ public interface DictionaryRecordRepository extends JpaRepository<DictionaryReco
@Param("scopesCsv") String[] scopesCsv,
@Param("at") OffsetDateTime at,
@Param("polygonGeoJson") String polygonGeoJson);
/**
* Bucketed list snapshot points: timestamps когда records появлялись
* (validFrom) в заданном time window. Используется для TimeTravel
* slider marks на admin-UI — каждая mark = "interesting" moment где
* минимум одна запись changed.
*
* <p>Granularity: PostgreSQL date_trunc unit ({@code hour}, {@code day},
* {@code week}). Возвращает Object[] = {bucket OffsetDateTime,
* changedSinceLast Long}. Caller'а потом мапит на DTO.
*
* @param scopes — CSV array (passed как text[] для unnest in WHERE), скоупы
* которые caller видит. EMPTY → no records visible (caller
* anonymous и scope filter empty).
*/
@Query(value = """
SELECT date_trunc(:granularity, valid_from)::timestamptz AS bucket,
count(*) AS changed_since_last
FROM dictionary_records
WHERE dictionary_id = :dictionaryId
AND data_scope = ANY (string_to_array(:scopesCsv, ','))
AND valid_from >= :from
AND valid_from < :to
GROUP BY bucket
ORDER BY bucket DESC
""", nativeQuery = true)
List<Object[]> findSnapshotBuckets(
@Param("dictionaryId") UUID dictionaryId,
@Param("scopesCsv") String scopesCsv,
@Param("from") OffsetDateTime from,
@Param("to") OffsetDateTime to,
@Param("granularity") String granularity);
}
@@ -0,0 +1,26 @@
package cloud.nstart.terravault.ordinis.restapi.dto;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* Inline single-field schema edit per backend #4 в pending-endpoints brief.
*
* <p>Structured op (вместо JSON Patch RFC 6902) — более ergonomic для UI
* Fields tab inline edit. Frontend каждый field row кликает "edit" → form
* показывает только allowed (non-breaking) changes → submits эти deltas.
*
* @param expectedHeadVersion optimistic-lock — schema_version текущий
* head. Если в БД другой → 409 version_mismatch.
* @param field property name внутри schema.properties (например
* "altitude").
* @param changes key→value pairs изменения. Allowed keys:
* description, title, minimum, maximum, enum (только
* additions). Breaking keys (type, format, x-references)
* → 422.
*/
public record PatchSchemaRequest(
@NotBlank String expectedHeadVersion,
@NotBlank String field,
@NotNull JsonNode changes) {}
@@ -31,4 +31,8 @@ public class OrdinisException extends RuntimeException {
public static OrdinisException forbidden(String code, String message) {
return new OrdinisException(HttpStatus.FORBIDDEN, code, message);
}
public static OrdinisException unprocessableEntity(String code, String message) {
return new OrdinisException(HttpStatus.UNPROCESSABLE_ENTITY, code, message);
}
}
@@ -0,0 +1,237 @@
package cloud.nstart.terravault.ordinis.restapi.service;
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger;
import cloud.nstart.terravault.ordinis.restapi.dto.PatchSchemaRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
/**
* Inline non-breaking schema patch — backend #4 из pending-endpoints brief.
*
* <p>Применяет узкий allow-list mutations к одному property без breaking
* existing records. Bumps schema_version в patch component (1.4.0 → 1.4.1).
* Cascades через DictionaryDefinitionService.updateSchema чтобы переиспользовать
* outbox emit + audit logging.
*
* <p>Allowed change keys:
* <ul>
* <li>{@code description} — JsonNode (localized object или plain string)</li>
* <li>{@code title} — localized object</li>
* <li>{@code minimum} / {@code maximum} — numeric (expansion only —
* narrowing rejected т.к. может invalidate existing records)</li>
* <li>{@code enum} — array. Mutation должна только ADD values, not remove
* (rejecting → 422 breaking_change_requires_draft)</li>
* </ul>
*
* <p>Breaking change keys (type, format, required, x-references, *Of) →
* 422 {@code breaking_change_requires_draft}.
*/
@Service
public class SchemaPatchService {
private static final Logger log = LoggerFactory.getLogger(SchemaPatchService.class);
private static final Set<String> ALLOWED_KEYS = Set.of(
"description", "title", "minimum", "maximum", "enum");
private final DictionaryDefinitionRepository repository;
private final DictionaryDefinitionService definitionService;
private final ScopeContext scopeContext;
/** Optional — null в test ctor'е. */
private final AuditLogger auditLogger;
@Autowired
public SchemaPatchService(
DictionaryDefinitionRepository repository,
DictionaryDefinitionService definitionService,
ScopeContext scopeContext,
AuditLogger auditLogger) {
this.repository = repository;
this.definitionService = definitionService;
this.scopeContext = scopeContext;
this.auditLogger = auditLogger;
}
public SchemaPatchService(
DictionaryDefinitionRepository repository,
DictionaryDefinitionService definitionService,
ScopeContext scopeContext) {
this(repository, definitionService, scopeContext, null);
}
/**
* Apply patch. Throws OrdinisException на validation issues:
* <ul>
* <li>404 {@code dictionary_not_found}</li>
* <li>404 {@code field_not_found} — field отсутствует в schema.properties</li>
* <li>409 {@code version_mismatch} — expectedHeadVersion stale</li>
* <li>422 {@code breaking_change_requires_draft} — patch trying breaking key</li>
* <li>422 {@code validation_failed} — numeric narrowing / enum removal</li>
* </ul>
*/
@Transactional
public PatchSchemaResponse patchSchema(String dictName, PatchSchemaRequest req) {
DictionaryDefinition d = definitionService.findByName(dictName);
if (!scopeContext.canAccess(d.getScope())) {
throw OrdinisException.notFound(
"dictionary_not_found", "Dictionary not found: " + dictName);
}
if (!d.getSchemaVersion().equals(req.expectedHeadVersion())) {
throw OrdinisException.conflict(
"version_mismatch",
"expectedHeadVersion=" + req.expectedHeadVersion()
+ " but current is " + d.getSchemaVersion());
}
JsonNode schema = d.getSchemaJson();
if (!(schema instanceof ObjectNode schemaObj)) {
throw OrdinisException.badRequest(
"invalid_schema", "Schema is not a JSON object");
}
JsonNode propertiesNode = schemaObj.get("properties");
if (!(propertiesNode instanceof ObjectNode props)) {
throw OrdinisException.notFound(
"field_not_found", "Schema has no properties");
}
JsonNode targetProp = props.get(req.field());
if (!(targetProp instanceof ObjectNode targetObj)) {
throw OrdinisException.notFound(
"field_not_found", "Field not in schema.properties: " + req.field());
}
JsonNode changesNode = req.changes();
if (!changesNode.isObject()) {
throw OrdinisException.badRequest(
"invalid_changes", "changes must be a JSON object");
}
// Validate каждый change key + apply на копию target prop.
ObjectNode patched = targetObj.deepCopy();
Iterator<Map.Entry<String, JsonNode>> it = changesNode.fields();
while (it.hasNext()) {
Map.Entry<String, JsonNode> entry = it.next();
String key = entry.getKey();
JsonNode newValue = entry.getValue();
if (!ALLOWED_KEYS.contains(key)) {
throw OrdinisException.unprocessableEntity(
"breaking_change_requires_draft",
"Key '" + key + "' requires workflow draft, not inline patch."
+ " Allowed inline keys: " + ALLOWED_KEYS);
}
JsonNode oldValue = targetObj.get(key);
validateChange(key, oldValue, newValue);
patched.set(key, newValue);
}
// Apply patched property в новый schemaJson + bump version.
ObjectNode newProps = props.deepCopy();
newProps.set(req.field(), patched);
ObjectNode newSchema = schemaObj.deepCopy();
newSchema.set("properties", newProps);
String oldVersion = d.getSchemaVersion();
String newVersion = bumpPatchVersion(oldVersion);
JsonNode prevSchema = d.getSchemaJson();
d.setSchemaJson(newSchema);
d.setSchemaVersion(newVersion);
DictionaryDefinition saved = repository.save(d);
if (auditLogger != null) {
auditLogger.definitionUpdated(saved, prevSchema);
}
log.info("Schema patched: dict={} field={} oldVersion={} newVersion={}",
dictName, req.field(), oldVersion, newVersion);
return new PatchSchemaResponse(newVersion, saved.getSchemaJson());
}
/** Per-key non-breaking validation. */
private static void validateChange(String key, JsonNode oldValue, JsonNode newValue) {
switch (key) {
case "description":
case "title":
// Free-form text/object — всегда non-breaking.
return;
case "minimum": {
if (oldValue != null && oldValue.isNumber() && newValue.isNumber()) {
double oldNum = oldValue.doubleValue();
double newNum = newValue.doubleValue();
if (newNum > oldNum) {
throw OrdinisException.unprocessableEntity(
"validation_failed",
"minimum tighter (" + newNum + " > " + oldNum
+ ") могло бы invalidate existing records. Use draft.");
}
}
return;
}
case "maximum": {
if (oldValue != null && oldValue.isNumber() && newValue.isNumber()) {
double oldNum = oldValue.doubleValue();
double newNum = newValue.doubleValue();
if (newNum < oldNum) {
throw OrdinisException.unprocessableEntity(
"validation_failed",
"maximum tighter (" + newNum + " < " + oldNum
+ ") могло бы invalidate existing records. Use draft.");
}
}
return;
}
case "enum": {
if (oldValue != null && oldValue.isArray() && newValue.isArray()) {
// new enum должен содержать все старые values (only-additions).
java.util.Set<String> oldSet = new java.util.HashSet<>();
oldValue.forEach(v -> oldSet.add(v.asText()));
java.util.Set<String> newSet = new java.util.HashSet<>();
newValue.forEach(v -> newSet.add(v.asText()));
if (!newSet.containsAll(oldSet)) {
oldSet.removeAll(newSet);
throw OrdinisException.unprocessableEntity(
"validation_failed",
"enum removes existing values: " + oldSet + ". Use draft.");
}
}
return;
}
default:
// Shouldn't happen — ALLOWED_KEYS check выше.
throw OrdinisException.unprocessableEntity(
"breaking_change_requires_draft", "Key not allowed: " + key);
}
}
/**
* Bump semver patch component (1.4.0 → 1.4.1). Non-semver versions
* → append ".1" / "+1". Best-effort, не throws.
*/
static String bumpPatchVersion(String version) {
if (version == null || version.isBlank()) return "1.0.1";
String[] parts = version.split("\\.");
if (parts.length != 3) return version + ".1";
try {
int patch = Integer.parseInt(parts[2]);
return parts[0] + "." + parts[1] + "." + (patch + 1);
} catch (NumberFormatException e) {
return version + ".1";
}
}
// === DTOs ===
public record PatchSchemaResponse(String newVersion, JsonNode schemaJson) {}
}
@@ -0,0 +1,120 @@
package cloud.nstart.terravault.ordinis.restapi.service;
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Snapshots service для TimeTravel slider marks. Возвращает bucketed list
* timestamps где минимум одна запись была inserted/updated в window. UI
* затем snap'ает slider к этим точкам — wider granularity = меньше marks
* (clearer UX на больших windows).
*
* <p>Granularity = PostgreSQL date_trunc unit. Поддерживаются: hour, day,
* week. Other unit'ы (year, month) overkill для admin scenarios (типичный
* window 30-90 дней).
*
* <p>Label: backend formatits relative RU label ("1 апр", "15 апр",
* "сейчас"). Frontend не делает locale logic — single source of truth.
*/
@Service
public class SnapshotsService {
private static final Set<String> ALLOWED_GRANULARITY = Set.of("hour", "day", "week");
private static final DateTimeFormatter RU_DAY = DateTimeFormatter.ofPattern("d MMM", new Locale("ru"));
private static final DateTimeFormatter RU_HOUR = DateTimeFormatter.ofPattern("d MMM HH:mm", new Locale("ru"));
private final DictionaryDefinitionService definitionService;
private final DictionaryRecordRepository recordRepository;
private final ScopeContext scopeContext;
public SnapshotsService(
DictionaryDefinitionService definitionService,
DictionaryRecordRepository recordRepository,
ScopeContext scopeContext) {
this.definitionService = definitionService;
this.recordRepository = recordRepository;
this.scopeContext = scopeContext;
}
/**
* @throws OrdinisException 404 если dict не существует / не виден;
* 400 если granularity недопустимый.
*/
@Transactional(readOnly = true)
public SnapshotsResponse findSnapshots(
String name,
OffsetDateTime from,
OffsetDateTime to,
String granularity) {
DictionaryDefinition d = definitionService.findByName(name);
if (!scopeContext.canAccess(d.getScope())) {
throw OrdinisException.notFound(
"dictionary_not_found", "Dictionary not found: " + name);
}
String gran = granularity == null ? "day" : granularity.toLowerCase();
if (!ALLOWED_GRANULARITY.contains(gran)) {
throw OrdinisException.badRequest(
"invalid_granularity",
"granularity must be one of " + ALLOWED_GRANULARITY + ", got: " + granularity);
}
OffsetDateTime now = OffsetDateTime.now();
OffsetDateTime windowTo = to == null ? now : to;
OffsetDateTime windowFrom = from == null ? windowTo.minusDays(30) : from;
if (!windowFrom.isBefore(windowTo)) {
throw OrdinisException.badRequest(
"invalid_window", "from must be before to: from=" + windowFrom + " to=" + windowTo);
}
Set<DataScope> scopes = scopeContext.currentScopes();
String scopesCsv = scopes.stream().map(Enum::name).collect(Collectors.joining(","));
List<Object[]> rows = recordRepository.findSnapshotBuckets(
d.getId(), scopesCsv, windowFrom, windowTo, gran);
List<Snapshot> snapshots = new ArrayList<>(rows.size());
boolean currentMarked = false;
DateTimeFormatter fmt = "hour".equals(gran) ? RU_HOUR : RU_DAY;
for (Object[] row : rows) {
OffsetDateTime bucket = ((java.sql.Timestamp) row[0]).toInstant().atOffset(now.getOffset());
long changedSinceLast = ((Number) row[1]).longValue();
boolean isCurrent = !currentMarked && !bucket.isAfter(now);
if (isCurrent) currentMarked = true;
String label = isCurrent ? "сейчас" : fmt.format(bucket);
// totalRecords остаётся 0 в MVP — точный cumulative count требует
// отдельный window query. UI показывает только bucket + changed count.
snapshots.add(new Snapshot(bucket, 0L, changedSinceLast, label, isCurrent));
}
// Reverse чтобы oldest first (UI slider читает слева-направо).
java.util.Collections.reverse(snapshots);
return new SnapshotsResponse(d.getName(), windowFrom, windowTo, snapshots);
}
// === DTOs ===
public record Snapshot(
OffsetDateTime at,
long totalRecords,
long changedSinceLast,
String label,
boolean isCurrent) {}
public record SnapshotsResponse(
String dictionary,
OffsetDateTime from,
OffsetDateTime to,
List<Snapshot> snapshots) {}
}
@@ -1,6 +1,7 @@
package cloud.nstart.terravault.ordinis.restapi.web;
import cloud.nstart.terravault.ordinis.restapi.service.ChangelogService;
import cloud.nstart.terravault.ordinis.restapi.service.SnapshotsService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -8,27 +9,33 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* {@code GET /api/v1/dictionaries/{name}/changelog} — schema versions
* timeline для admin-UI History tab и TimeTravel modal.
*
* <p>Source: audit_log filtered by DEFINITION_* event types. Каждая запись
* содержит payload_before/after = JsonNode schemas; ChangelogService
* computes property-level diff summary.
* Per-dictionary history / time-travel endpoints для admin-UI:
* <ul>
* <li>{@code GET /dictionaries/{name}/changelog} — schema versions timeline</li>
* <li>{@code GET /dictionaries/{name}/snapshots} — record-change buckets для TimeTravel slider</li>
* </ul>
*
* <p>Errors:
* <ul>
* <li>404 {@code dictionary_not_found} — dict не существует или не виден
* caller'у (одинаковая ошибка чтобы не leak'ало existence).</li>
* <li>400 {@code invalid_granularity} — snapshots granularity не из
* {hour, day, week}.</li>
* <li>400 {@code invalid_window} — from >= to.</li>
* </ul>
*/
@RestController
@RequestMapping("/api/v1/dictionaries")
public class DictionaryChangelogController {
private final ChangelogService service;
private final ChangelogService changelogService;
private final SnapshotsService snapshotsService;
public DictionaryChangelogController(ChangelogService service) {
this.service = service;
public DictionaryChangelogController(
ChangelogService changelogService,
SnapshotsService snapshotsService) {
this.changelogService = changelogService;
this.snapshotsService = snapshotsService;
}
/**
@@ -41,6 +48,28 @@ public class DictionaryChangelogController {
public ChangelogService.ChangelogResponse changelog(
@PathVariable String name,
@RequestParam(defaultValue = "50") int limit) {
return service.findChangelog(name, limit);
return changelogService.findChangelog(name, limit);
}
/**
* Snapshot points (record-change buckets) для TimeTravel slider.
*
* @param name dict business name
* @param from ISO datetime window start. Default: now - 30d
* @param to ISO datetime window end. Default: now
* @param granularity bucket size — {@code hour} / {@code day} / {@code week}.
* Default: {@code day}.
*/
@GetMapping("/{name}/snapshots")
public SnapshotsService.SnapshotsResponse snapshots(
@PathVariable String name,
@RequestParam(required = false)
@org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE_TIME)
java.time.OffsetDateTime from,
@RequestParam(required = false)
@org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE_TIME)
java.time.OffsetDateTime to,
@RequestParam(defaultValue = "day") String granularity) {
return snapshotsService.findSnapshots(name, from, to, granularity);
}
}
@@ -5,11 +5,14 @@ import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest;
import cloud.nstart.terravault.ordinis.restapi.dto.DictionaryResponse;
import cloud.nstart.terravault.ordinis.restapi.dto.PatchSchemaRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
import cloud.nstart.terravault.ordinis.restapi.service.SchemaPatchService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
@@ -32,14 +35,17 @@ public class DictionaryDefinitionController {
private final DictionaryDefinitionService service;
private final ScopeContext scopeContext;
private final DictionaryRecordRepository recordRepository;
private final SchemaPatchService schemaPatchService;
public DictionaryDefinitionController(
DictionaryDefinitionService service,
ScopeContext scopeContext,
DictionaryRecordRepository recordRepository) {
DictionaryRecordRepository recordRepository,
SchemaPatchService schemaPatchService) {
this.service = service;
this.scopeContext = scopeContext;
this.recordRepository = recordRepository;
this.schemaPatchService = schemaPatchService;
}
@GetMapping
@@ -102,4 +108,25 @@ public class DictionaryDefinitionController {
}
return DictionaryResponse.from(service.updateSchema(name, req));
}
/**
* Inline non-breaking field patch — backend #4 из pending-endpoints brief.
* Bumps patch component (1.4.0 → 1.4.1). Только allow-listed keys
* (description/title/minimum/maximum/enum).
*
* <p>Errors:
* <ul>
* <li>404 {@code dictionary_not_found}</li>
* <li>404 {@code field_not_found}</li>
* <li>409 {@code version_mismatch}</li>
* <li>422 {@code breaking_change_requires_draft} / {@code validation_failed}</li>
* </ul>
*/
@PatchMapping("/{name}/schema")
public SchemaPatchService.PatchSchemaResponse patchSchema(
@PathVariable String name,
@org.springframework.web.bind.annotation.RequestBody
@jakarta.validation.Valid PatchSchemaRequest req) {
return schemaPatchService.patchSchema(name, req);
}
}
@@ -0,0 +1,181 @@
package cloud.nstart.terravault.ordinis.restapi.service;
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
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.restapi.dto.PatchSchemaRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
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.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.Mockito.mock;
import static org.mockito.Mockito.when;
class SchemaPatchServiceTest {
private static final ObjectMapper OM = new ObjectMapper();
private DictionaryDefinitionRepository repo;
private DictionaryDefinitionService defService;
private TestScopeContext scopeCtx;
private SchemaPatchService service;
@BeforeEach
void setUp() {
repo = mock(DictionaryDefinitionRepository.class);
defService = new DictionaryDefinitionService(repo, null);
scopeCtx = new TestScopeContext();
service = new SchemaPatchService(repo, defService, scopeCtx);
when(repo.save(org.mockito.ArgumentMatchers.any(DictionaryDefinition.class)))
.thenAnswer(inv -> inv.getArgument(0));
}
static final class TestScopeContext extends ScopeContext {
private DataScope blockedScope;
TestScopeContext() { super(null); }
@Override public boolean canAccess(DataScope dataScope) {
return dataScope != blockedScope;
}
@Override public java.util.Set<DataScope> currentScopes() {
return java.util.Set.of(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED);
}
void block(DataScope s) { this.blockedScope = s; }
}
@Test
void patch_versionMismatch_409() throws Exception {
var d = def("dict", "1.4.0",
"{\"properties\":{\"altitude\":{\"type\":\"integer\"}}}");
when(repo.findByName("dict")).thenReturn(Optional.of(d));
var req = new PatchSchemaRequest("1.0.0", "altitude",
OM.readTree("{\"description\":\"updated\"}"));
assertThatThrownBy(() -> service.patchSchema("dict", req))
.isInstanceOfSatisfying(OrdinisException.class,
e -> assertThat(e.getCode()).isEqualTo("version_mismatch"));
}
@Test
void patch_fieldNotFound_404() throws Exception {
var d = def("dict", "1.4.0",
"{\"properties\":{\"altitude\":{\"type\":\"integer\"}}}");
when(repo.findByName("dict")).thenReturn(Optional.of(d));
var req = new PatchSchemaRequest("1.4.0", "mass",
OM.readTree("{\"description\":\"x\"}"));
assertThatThrownBy(() -> service.patchSchema("dict", req))
.isInstanceOfSatisfying(OrdinisException.class,
e -> assertThat(e.getCode()).isEqualTo("field_not_found"));
}
@Test
void patch_breakingKey_422() throws Exception {
var d = def("dict", "1.4.0",
"{\"properties\":{\"altitude\":{\"type\":\"integer\"}}}");
when(repo.findByName("dict")).thenReturn(Optional.of(d));
var req = new PatchSchemaRequest("1.4.0", "altitude",
OM.readTree("{\"type\":\"string\"}"));
assertThatThrownBy(() -> service.patchSchema("dict", req))
.isInstanceOfSatisfying(OrdinisException.class,
e -> assertThat(e.getCode()).isEqualTo("breaking_change_requires_draft"));
}
@Test
void patch_descriptionUpdate_bumpsPatchVersion() throws Exception {
var d = def("dict", "1.4.0",
"{\"properties\":{\"altitude\":{\"type\":\"integer\"}}}");
when(repo.findByName("dict")).thenReturn(Optional.of(d));
var req = new PatchSchemaRequest("1.4.0", "altitude",
OM.readTree("{\"description\":{\"ru\":\"Высота орбиты\",\"en\":\"Altitude\"}}"));
var result = service.patchSchema("dict", req);
assertThat(result.newVersion()).isEqualTo("1.4.1");
JsonNode altitude = result.schemaJson().get("properties").get("altitude");
assertThat(altitude.get("description").get("ru").asText()).isEqualTo("Высота орбиты");
}
@Test
void patch_minimumTighter_422() throws Exception {
var d = def("dict", "1.4.0",
"{\"properties\":{\"altitude\":{\"type\":\"integer\",\"minimum\":0}}}");
when(repo.findByName("dict")).thenReturn(Optional.of(d));
var req = new PatchSchemaRequest("1.4.0", "altitude",
OM.readTree("{\"minimum\":100}"));
assertThatThrownBy(() -> service.patchSchema("dict", req))
.isInstanceOfSatisfying(OrdinisException.class,
e -> assertThat(e.getCode()).isEqualTo("validation_failed"));
}
@Test
void patch_minimumExpanded_ok() throws Exception {
var d = def("dict", "1.4.0",
"{\"properties\":{\"altitude\":{\"type\":\"integer\",\"minimum\":100}}}");
when(repo.findByName("dict")).thenReturn(Optional.of(d));
var req = new PatchSchemaRequest("1.4.0", "altitude",
OM.readTree("{\"minimum\":0}"));
var result = service.patchSchema("dict", req);
assertThat(result.newVersion()).isEqualTo("1.4.1");
}
@Test
void patch_enumRemove_422() throws Exception {
var d = def("dict", "1.4.0", """
{"properties":{"status":{"type":"string","enum":["live","draft","archived"]}}}""");
when(repo.findByName("dict")).thenReturn(Optional.of(d));
var req = new PatchSchemaRequest("1.4.0", "status",
OM.readTree("{\"enum\":[\"live\",\"draft\"]}"));
assertThatThrownBy(() -> service.patchSchema("dict", req))
.isInstanceOfSatisfying(OrdinisException.class,
e -> assertThat(e.getCode()).isEqualTo("validation_failed"));
}
@Test
void patch_enumAdd_ok() throws Exception {
var d = def("dict", "1.4.0",
"{\"properties\":{\"status\":{\"type\":\"string\",\"enum\":[\"live\"]}}}");
when(repo.findByName("dict")).thenReturn(Optional.of(d));
var req = new PatchSchemaRequest("1.4.0", "status",
OM.readTree("{\"enum\":[\"live\",\"draft\",\"archived\"]}"));
var result = service.patchSchema("dict", req);
assertThat(result.newVersion()).isEqualTo("1.4.1");
assertThat(result.schemaJson().get("properties").get("status").get("enum"))
.hasSize(3);
}
@Test
void bumpPatchVersion_basic() {
assertThat(SchemaPatchService.bumpPatchVersion("1.4.0")).isEqualTo("1.4.1");
assertThat(SchemaPatchService.bumpPatchVersion("0.0.99")).isEqualTo("0.0.100");
assertThat(SchemaPatchService.bumpPatchVersion("2.0.0")).isEqualTo("2.0.1");
}
@Test
void bumpPatchVersion_nonSemver() {
assertThat(SchemaPatchService.bumpPatchVersion("v1")).isEqualTo("v1.1");
assertThat(SchemaPatchService.bumpPatchVersion("")).isEqualTo("1.0.1");
assertThat(SchemaPatchService.bumpPatchVersion(null)).isEqualTo("1.0.1");
}
private static DictionaryDefinition def(String name, String version, String schemaJson) {
JsonNode schema;
try {
schema = OM.readTree(schemaJson);
} catch (Exception e) {
throw new RuntimeException(e);
}
var d = new DictionaryDefinition(UUID.randomUUID(), name, DataScope.PUBLIC, schema);
d.setSchemaVersion(version);
return d;
}
}
@@ -0,0 +1,149 @@
package cloud.nstart.terravault.ordinis.restapi.service;
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
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.restapi.error.OrdinisException;
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.sql.Timestamp;
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 SnapshotsServiceTest {
private static final ObjectMapper OM = new ObjectMapper();
private DictionaryDefinitionRepository defRepo;
private DictionaryRecordRepository recRepo;
private DictionaryDefinitionService defService;
private TestScopeContext scopeCtx;
private SnapshotsService service;
@BeforeEach
void setUp() {
defRepo = mock(DictionaryDefinitionRepository.class);
recRepo = mock(DictionaryRecordRepository.class);
defService = new DictionaryDefinitionService(defRepo, null);
scopeCtx = new TestScopeContext();
service = new SnapshotsService(defService, recRepo, scopeCtx);
}
static final class TestScopeContext extends ScopeContext {
private DataScope blockedScope;
TestScopeContext() { super(null); }
@Override public boolean canAccess(DataScope dataScope) {
return dataScope != blockedScope;
}
@Override public java.util.Set<DataScope> currentScopes() {
return java.util.Set.of(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED);
}
void block(DataScope s) { this.blockedScope = s; }
}
@Test
void snapshots_dictNotFound_404() {
when(defRepo.findByName("missing")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.findSnapshots("missing", null, null, "day"))
.isInstanceOf(OrdinisException.class);
}
@Test
void snapshots_scopeHidden_404() {
var d = def("private", DataScope.RESTRICTED);
when(defRepo.findByName("private")).thenReturn(Optional.of(d));
scopeCtx.block(DataScope.RESTRICTED);
assertThatThrownBy(() -> service.findSnapshots("private", null, null, "day"))
.isInstanceOf(OrdinisException.class);
}
@Test
void snapshots_invalidGranularity_400() {
var d = def("dict", DataScope.PUBLIC);
when(defRepo.findByName("dict")).thenReturn(Optional.of(d));
assertThatThrownBy(() -> service.findSnapshots("dict", null, null, "century"))
.isInstanceOf(OrdinisException.class)
.hasMessageContaining("granularity");
}
@Test
void snapshots_invalidWindow_400() {
var d = def("dict", DataScope.PUBLIC);
when(defRepo.findByName("dict")).thenReturn(Optional.of(d));
OffsetDateTime from = OffsetDateTime.parse("2026-05-11T00:00:00Z");
OffsetDateTime to = OffsetDateTime.parse("2026-04-11T00:00:00Z");
assertThatThrownBy(() -> service.findSnapshots("dict", from, to, "day"))
.isInstanceOf(OrdinisException.class)
.hasMessageContaining("from must be before to");
}
@Test
void snapshots_basicBuckets_mapToSnapshots() {
var d = def("dict", DataScope.PUBLIC);
when(defRepo.findByName("dict")).thenReturn(Optional.of(d));
Timestamp april = Timestamp.from(OffsetDateTime.parse("2026-04-01T00:00:00Z").toInstant());
Timestamp may = Timestamp.from(OffsetDateTime.parse("2026-05-11T00:00:00Z").toInstant());
when(recRepo.findSnapshotBuckets(eq(d.getId()), anyString(), any(), any(), eq("day")))
.thenReturn(List.<Object[]>of(
new Object[]{may, 5L},
new Object[]{april, 3L}));
var result = service.findSnapshots("dict", null, null, "day");
assertThat(result.dictionary()).isEqualTo("dict");
assertThat(result.snapshots()).hasSize(2);
// Reversed → oldest first.
assertThat(result.snapshots().get(0).changedSinceLast()).isEqualTo(3L);
assertThat(result.snapshots().get(1).changedSinceLast()).isEqualTo(5L);
}
@Test
void snapshots_currentMarkedOnce_evenWithFutureBuckets() {
var d = def("dict", DataScope.PUBLIC);
when(defRepo.findByName("dict")).thenReturn(Optional.of(d));
OffsetDateTime now = OffsetDateTime.now();
Timestamp pastA = Timestamp.from(now.minusDays(10).toInstant());
Timestamp pastB = Timestamp.from(now.minusDays(5).toInstant());
// Repository returns DESC, так order — newest first.
when(recRepo.findSnapshotBuckets(any(), anyString(), any(), any(), anyString()))
.thenReturn(List.<Object[]>of(
new Object[]{pastB, 1L},
new Object[]{pastA, 2L}));
var result = service.findSnapshots("dict", null, null, "day");
long isCurrentCount = result.snapshots().stream().filter(SnapshotsService.Snapshot::isCurrent).count();
assertThat(isCurrentCount).isEqualTo(1L);
// Newest bucket (pastB) — это "сейчас" label.
var newest = result.snapshots().get(result.snapshots().size() - 1);
assertThat(newest.isCurrent()).isTrue();
assertThat(newest.label()).isEqualTo("сейчас");
}
// === Helpers ===
private static DictionaryDefinition def(String name, DataScope scope) {
JsonNode schema;
try {
schema = OM.readTree("{\"properties\":{}}");
} catch (Exception e) {
throw new RuntimeException(e);
}
return new DictionaryDefinition(UUID.randomUUID(), name, scope, schema);
}
}