From ebc90d76e97dbdeb42f18cb0578b5854ed6896f4 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 11 May 2026 23:04:09 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(api):=20GET=20/dictionaries/{name}/cha?= =?UTF-8?q?ngelog=20=E2=80=94=20schema=20versions=20timeline=20(backend=20?= =?UTF-8?q?#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend #1 из pending-endpoints brief. Frontend HistoryTabContent + TimeTravelModal используют этот endpoint для отображения версий схемы во времени с per-version property-level diff summary. Implementation: - AuditLogger.definitionCreated / definitionUpdated — пишет в audit_log с event_type DEFINITION_CREATED / DEFINITION_UPDATED. payload_before/ after = JsonNode schema. record_id / business_key — NULL (dict-level event). - DictionaryDefinitionService.create / updateSchema hook в audit logger (autowired, null-safe для tests). - AuditLogRepository.findByDictionaryIdAndEventTypes — query by dict_id + event_type IN (...), ORDER BY event_time DESC, paginated. - ChangelogService.findChangelog — собирает entries, computes top-level property-key diff (added/removed/changed). Первая entry (newest) = isCurrent + schemaVersion из DictionaryDefinition; старые entries возвращают version=null (semver-в-аудит backfill — separate migration). - DictionaryChangelogController — GET /dictionaries/{name}/changelog?limit=50 Errors: - 404 dictionary_not_found когда dict missing OR scope hidden (consistent с другими endpoints). Tests +9: dict-not-found, scope-hidden, empty-audit, first-is-current, diff-added/removed/changed/null-before/identical. --- .../domain/audit/AuditLogRepository.java | 16 ++ .../ordinis/restapi/audit/AuditLogger.java | 28 ++- .../restapi/service/ChangelogService.java | 178 ++++++++++++++++ .../service/DictionaryDefinitionService.java | 25 ++- .../web/DictionaryChangelogController.java | 46 ++++ .../restapi/service/ChangelogServiceTest.java | 197 ++++++++++++++++++ 6 files changed, 486 insertions(+), 4 deletions(-) create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogService.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryChangelogController.java create mode 100644 ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogServiceTest.java diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java index deec49b..d925af4 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLogRepository.java @@ -14,6 +14,22 @@ public interface AuditLogRepository extends JpaRepository { List findByDictionaryIdOrderByEventTimeDesc(UUID dictionaryId); + /** + * Audit entries для конкретного dict, отфильтрованные по event_type IN + * списке. Используется ChangelogService — берёт только DEFINITION_* + * events, skip RECORD_* шум. + */ + @Query(""" + SELECT a FROM AuditLog a + WHERE a.dictionaryId = :dictionaryId + AND a.eventType IN :eventTypes + ORDER BY a.eventTime DESC + """) + List findByDictionaryIdAndEventTypes( + @Param("dictionaryId") UUID dictionaryId, + @Param("eventTypes") List eventTypes, + Pageable pageable); + List findByRecordIdOrderByEventTimeDesc(UUID recordId); List findByUserIdOrderByEventTimeDesc(String userId); diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java index 8d7855e..3a5d866 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java @@ -48,6 +48,24 @@ public class AuditLogger { write("RECORD_CREATED", "CREATE", d, r, null, r.getData()); } + /** + * Audit для создания справочника. record-связанные поля null'ятся + * (record_id / business_key не применимы к dict-level event). + * payload_after = schema_json текущей версии. + */ + public void definitionCreated(DictionaryDefinition d) { + write("DEFINITION_CREATED", "CREATE", d, null, null, d.getSchemaJson()); + } + + /** + * Audit для обновления schema справочника. payload_before — schema до + * update'а, payload_after — после. Используется для changelog / diff + * computation в frontend History tab. + */ + public void definitionUpdated(DictionaryDefinition d, JsonNode prevSchema) { + write("DEFINITION_UPDATED", "UPDATE", d, null, prevSchema, d.getSchemaJson()); + } + public void recordUpdate( DictionaryDefinition d, DictionaryRecord r, JsonNode payloadBefore) { write("RECORD_UPDATED", "UPDATE", d, r, payloadBefore, r.getData()); @@ -71,8 +89,12 @@ public class AuditLogger { entry.setAction(action); entry.setDictionaryId(d.getId()); entry.setDictionaryName(d.getName()); - entry.setRecordId(r.getId()); - entry.setBusinessKey(r.getBusinessKey()); + // Record-level events заполняют record_id / business_key; dict-level + // events (DEFINITION_*) проходят с r=null — оставляем NULL'ы. + if (r != null) { + entry.setRecordId(r.getId()); + entry.setBusinessKey(r.getBusinessKey()); + } entry.setPayloadBefore(before); entry.setPayloadAfter(after); @@ -92,7 +114,7 @@ public class AuditLogger { // алертилось. Compliance-кейс ловит на этой ошибке. log.error( "Failed to write audit_log entry: dictionary={} record_id={} action={}: {}", - d.getName(), r.getId(), action, ex.getMessage(), ex); + d.getName(), r == null ? null : r.getId(), action, ex.getMessage(), ex); } } diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogService.java new file mode 100644 index 0000000..edda0af --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogService.java @@ -0,0 +1,178 @@ +package cloud.nstart.terravault.ordinis.restapi.service; + +import cloud.nstart.terravault.ordinis.auth.ScopeContext; +import cloud.nstart.terravault.ordinis.domain.audit.AuditLog; +import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * Builds schema changelog timeline для admin-UI History tab. Reads audit_log + * (DEFINITION_CREATED / DEFINITION_UPDATED events) и формирует entries с + * version / publishedAt / publishedBy / diff summary. + * + *

«Version» tracking: каждая audit_log row не хранит schema_version + * (на момент write мы знаем только текущий — он же финальный для этой row). + * Сейчас фронту нужно показать sequence; semver value берётся только для + * HEAD entry из {@link DictionaryDefinition#getSchemaVersion()}. Старые + * entries показываются с null version + dates. Backfill semver-в-аудит + * затея отдельной миграции (см. TODO inline). + * + *

Diff computation — best-effort key-set comparison: + *

    + *
  • {@code added}: ключи которые есть в payload_after.properties но не в before
  • + *
  • {@code removed}: ключи в before но не в after
  • + *
  • {@code changed}: ключи в обоих, но JsonNode#equals == false
  • + *
+ * + *

Frontend строит human-readable строки из этих списков ("+altitude / + * ~operator"). Пустой diff (после=до) — мутация прошла но без property + * изменений (description/metadata), показываем "—" в UI. + */ +@Service +public class ChangelogService { + + private static final List DEFINITION_EVENT_TYPES = + List.of("DEFINITION_CREATED", "DEFINITION_UPDATED"); + + private final AuditLogRepository auditRepo; + private final DictionaryDefinitionService definitionService; + private final ScopeContext scopeContext; + + public ChangelogService( + AuditLogRepository auditRepo, + DictionaryDefinitionService definitionService, + ScopeContext scopeContext) { + this.auditRepo = auditRepo; + this.definitionService = definitionService; + this.scopeContext = scopeContext; + } + + /** + * Build changelog response для dict. throws 404 если dict не существует + * либо caller не имеет к нему scope access. + * + * @param name dict name + * @param limit max entries (cap 200) + */ + @Transactional(readOnly = true) + public ChangelogResponse findChangelog(String name, int limit) { + DictionaryDefinition d = definitionService.findByName(name); + if (!scopeContext.canAccess(d.getScope())) { + throw OrdinisException.notFound( + "dictionary_not_found", "Dictionary not found: " + name); + } + int safeLimit = Math.max(1, Math.min(limit, 200)); + List rows = auditRepo.findByDictionaryIdAndEventTypes( + d.getId(), DEFINITION_EVENT_TYPES, PageRequest.of(0, safeLimit)); + + List entries = new ArrayList<>(rows.size()); + for (int i = 0; i < rows.size(); i++) { + AuditLog row = rows.get(i); + boolean isCurrent = i == 0; // most recent — assumed current (DESC sort) + String version = isCurrent ? d.getSchemaVersion() : null; + DiffSummary diff = computeDiff(row.getPayloadBefore(), row.getPayloadAfter()); + entries.add(new ChangelogEntry( + version, + row.getEventTime(), + new Publisher(row.getUserId(), row.getUserId()), + null, // headers (commit msg) not captured yet — see TODO + diff, + 0, // recordsAffected — not tracked at audit level; future field + isCurrent)); + } + + return new ChangelogResponse(d.getName(), d.getSchemaVersion(), entries); + } + + /** + * Compute property-key diff between two schema JsonNodes. Both nullable — + * NULL before = creation, NULL after = (deletion, не используется сейчас). + * + *

Only top-level properties are diffed — глубокая иерархия (nested + * schemas, allOf etc) сейчас treated как opaque "changed". Достаточно + * для UI ("+altitude / ~operator" summary). + */ + static DiffSummary computeDiff(JsonNode before, JsonNode after) { + Set beforeKeys = topLevelPropertyKeys(before); + Set afterKeys = topLevelPropertyKeys(after); + + List added = new ArrayList<>(); + List removed = new ArrayList<>(); + List changed = new ArrayList<>(); + + Set all = new TreeSet<>(); + all.addAll(beforeKeys); + all.addAll(afterKeys); + + JsonNode beforeProps = before == null ? null : before.get("properties"); + JsonNode afterProps = after == null ? null : after.get("properties"); + + for (String k : all) { + boolean inBefore = beforeKeys.contains(k); + boolean inAfter = afterKeys.contains(k); + if (inBefore && !inAfter) { + removed.add(k); + } else if (!inBefore && inAfter) { + added.add(k); + } else { + JsonNode b = beforeProps == null ? null : beforeProps.get(k); + JsonNode a = afterProps == null ? null : afterProps.get(k); + if (b != null && a != null && !b.equals(a)) { + changed.add(k); + } + } + } + return new DiffSummary(added, removed, changed); + } + + private static Set topLevelPropertyKeys(JsonNode schema) { + if (schema == null || !schema.isObject()) return Set.of(); + JsonNode properties = schema.get("properties"); + if (properties == null || !properties.isObject()) return Set.of(); + Set keys = new LinkedHashSet<>(); + Iterator> it = properties.fields(); + while (it.hasNext()) keys.add(it.next().getKey()); + return keys; + } + + // === DTOs === + + /** Per-version entry. version=null для старых entries без backfill. */ + public record ChangelogEntry( + String version, + OffsetDateTime publishedAt, + Publisher publishedBy, + String headers, + DiffSummary diff, + int recordsAffected, + boolean isCurrent) {} + + /** Author identity. sub — JWT subject claim (Keycloak user id). */ + public record Publisher(String sub, String name) {} + + /** Property-level change summary. */ + public record DiffSummary( + List added, + List removed, + List changed) {} + + /** Top-level response. */ + public record ChangelogResponse( + String dictionary, + String currentVersion, + List entries) {} +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java index 3a375f7..81d18f0 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java @@ -6,8 +6,11 @@ import cloud.nstart.terravault.ordinis.events.EventEnvelope; import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionCreated; import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionUpdated; import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder; +import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger; import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest; import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,11 +22,23 @@ public class DictionaryDefinitionService { private final DictionaryDefinitionRepository repository; private final OutboxRecorder outboxRecorder; + /** Optional — None в tests/legacy конструкторе (без audit). */ + private final AuditLogger auditLogger; + @Autowired public DictionaryDefinitionService( - DictionaryDefinitionRepository repository, OutboxRecorder outboxRecorder) { + DictionaryDefinitionRepository repository, + OutboxRecorder outboxRecorder, + AuditLogger auditLogger) { this.repository = repository; this.outboxRecorder = outboxRecorder; + this.auditLogger = auditLogger; + } + + /** Test-friendly ctor без AuditLogger (existing tests без audit dep). */ + public DictionaryDefinitionService( + DictionaryDefinitionRepository repository, OutboxRecorder outboxRecorder) { + this(repository, outboxRecorder, null); } @Transactional(readOnly = true) @@ -99,6 +114,10 @@ public class DictionaryDefinitionService { "definition:" + saved.getName(), envelope); + if (auditLogger != null) { + auditLogger.definitionCreated(saved); + } + return saved; } @@ -155,6 +174,10 @@ public class DictionaryDefinitionService { "definition:" + saved.getName(), envelope); + if (auditLogger != null) { + auditLogger.definitionUpdated(saved, prevSchema); + } + return saved; } diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryChangelogController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryChangelogController.java new file mode 100644 index 0000000..fc64c12 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryChangelogController.java @@ -0,0 +1,46 @@ +package cloud.nstart.terravault.ordinis.restapi.web; + +import cloud.nstart.terravault.ordinis.restapi.service.ChangelogService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * {@code GET /api/v1/dictionaries/{name}/changelog} — schema versions + * timeline для admin-UI History tab и TimeTravel modal. + * + *

Source: audit_log filtered by DEFINITION_* event types. Каждая запись + * содержит payload_before/after = JsonNode schemas; ChangelogService + * computes property-level diff summary. + * + *

Errors: + *

    + *
  • 404 {@code dictionary_not_found} — dict не существует или не виден + * caller'у (одинаковая ошибка чтобы не leak'ало existence).
  • + *
+ */ +@RestController +@RequestMapping("/api/v1/dictionaries") +public class DictionaryChangelogController { + + private final ChangelogService service; + + public DictionaryChangelogController(ChangelogService service) { + this.service = service; + } + + /** + * @param name dict business name + * @param limit max entries (1..200, default 50). Pagination через cursor + * сейчас не нужен — типичный dict 5-30 versions. Cursor + * может быть добавлен без breaking change позже. + */ + @GetMapping("/{name}/changelog") + public ChangelogService.ChangelogResponse changelog( + @PathVariable String name, + @RequestParam(defaultValue = "50") int limit) { + return service.findChangelog(name, limit); + } +} diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogServiceTest.java new file mode 100644 index 0000000..d6dbcd8 --- /dev/null +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogServiceTest.java @@ -0,0 +1,197 @@ +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.audit.AuditLog; +import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository; +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 org.springframework.data.domain.Pageable; + +import java.util.Optional; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ChangelogServiceTest { + + private static final ObjectMapper OM = new ObjectMapper(); + + private AuditLogRepository auditRepo; + private DictionaryDefinitionRepository defRepo; + private DictionaryDefinitionService defService; + private TestScopeContext scopeCtx; + private ChangelogService service; + + @BeforeEach + void setUp() { + auditRepo = mock(AuditLogRepository.class); + defRepo = mock(DictionaryDefinitionRepository.class); + // Real service с mocked repo — Mockito не может subclass'ить + // DictionaryDefinitionService на JDK 25, поэтому строим реальный. + // OutboxRecorder=null — findChangelog не вызывает outbox path. + defService = new DictionaryDefinitionService(defRepo, null); + // Test stub для ScopeContext (Mockito не subclass'ит её на JDK 25). + scopeCtx = new TestScopeContext(); + service = new ChangelogService(auditRepo, defService, scopeCtx); + } + + /** Test-friendly ScopeContext subclass — позволяет программируемо tweak + * результат canAccess'а без Mockito (incompatible на JDK 25). */ + 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 currentScopes() { + return java.util.Set.of(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED); + } + void block(DataScope s) { this.blockedScope = s; } + } + + @Test + void changelog_dictNotFound_404() { + when(defRepo.findByName("missing")).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.findChangelog("missing", 50)) + .isInstanceOf(OrdinisException.class); + } + + @Test + void changelog_scopeHidden_404() { + var d = def("private", DataScope.RESTRICTED); + when(defRepo.findByName("private")).thenReturn(Optional.of(d)); + scopeCtx.block(DataScope.RESTRICTED); + assertThatThrownBy(() -> service.findChangelog("private", 50)) + .isInstanceOf(OrdinisException.class); + } + + @Test + void changelog_emptyAudit_returnsEmptyEntries() { + var d = def("dict", DataScope.PUBLIC); + when(defRepo.findByName("dict")).thenReturn(Optional.of(d)); + when(auditRepo.findByDictionaryIdAndEventTypes(any(), anyList(), any())) + .thenReturn(List.of()); + + var result = service.findChangelog("dict", 50); + + assertThat(result.dictionary()).isEqualTo("dict"); + assertThat(result.currentVersion()).isEqualTo("1.0.0"); + assertThat(result.entries()).isEmpty(); + } + + @Test + void changelog_firstEntryIsCurrent() throws Exception { + var d = def("dict", DataScope.PUBLIC); + d.setSchemaVersion("1.3.2"); + when(defRepo.findByName("dict")).thenReturn(Optional.of(d)); + + var newer = audit(d.getId(), "DEFINITION_UPDATED", "u1", + OffsetDateTime.parse("2026-05-01T10:00:00Z"), + OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}"), + OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"},\"b\":{\"type\":\"integer\"}}}")); + var older = audit(d.getId(), "DEFINITION_CREATED", "u0", + OffsetDateTime.parse("2026-04-01T10:00:00Z"), + null, + OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}")); + when(auditRepo.findByDictionaryIdAndEventTypes(any(), anyList(), any(Pageable.class))) + .thenReturn(List.of(newer, older)); + + var result = service.findChangelog("dict", 50); + + assertThat(result.entries()).hasSize(2); + assertThat(result.entries().get(0).isCurrent()).isTrue(); + assertThat(result.entries().get(0).version()).isEqualTo("1.3.2"); + assertThat(result.entries().get(1).isCurrent()).isFalse(); + assertThat(result.entries().get(1).version()).isNull(); + } + + // === Diff computation === + + @Test + void diff_addedKey() throws Exception { + JsonNode before = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}"); + JsonNode after = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"},\"b\":{\"type\":\"integer\"}}}"); + var diff = ChangelogService.computeDiff(before, after); + assertThat(diff.added()).containsExactly("b"); + assertThat(diff.removed()).isEmpty(); + assertThat(diff.changed()).isEmpty(); + } + + @Test + void diff_removedKey() throws Exception { + JsonNode before = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"},\"b\":{\"type\":\"integer\"}}}"); + JsonNode after = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}"); + var diff = ChangelogService.computeDiff(before, after); + assertThat(diff.removed()).containsExactly("b"); + assertThat(diff.added()).isEmpty(); + assertThat(diff.changed()).isEmpty(); + } + + @Test + void diff_changedKey() throws Exception { + JsonNode before = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}"); + JsonNode after = OM.readTree("{\"properties\":{\"a\":{\"type\":\"integer\"}}}"); + var diff = ChangelogService.computeDiff(before, after); + assertThat(diff.changed()).containsExactly("a"); + assertThat(diff.added()).isEmpty(); + assertThat(diff.removed()).isEmpty(); + } + + @Test + void diff_nullBefore_treatedAsCreation() throws Exception { + JsonNode after = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"},\"b\":{\"type\":\"integer\"}}}"); + var diff = ChangelogService.computeDiff(null, after); + assertThat(diff.added()).containsExactlyInAnyOrder("a", "b"); + assertThat(diff.removed()).isEmpty(); + assertThat(diff.changed()).isEmpty(); + } + + @Test + void diff_identicalSchemas_emptyDiff() throws Exception { + JsonNode schema = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}"); + var diff = ChangelogService.computeDiff(schema, schema); + assertThat(diff.added()).isEmpty(); + assertThat(diff.removed()).isEmpty(); + assertThat(diff.changed()).isEmpty(); + } + + // === Helpers === + + private static DictionaryDefinition def(String name, DataScope scope) { + JsonNode schema; + try { + schema = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}"); + } catch (Exception e) { + throw new RuntimeException(e); + } + return new DictionaryDefinition(UUID.randomUUID(), name, scope, schema); + } + + private static AuditLog audit( + UUID dictId, String eventType, String userId, + OffsetDateTime at, JsonNode before, JsonNode after) { + AuditLog row = new AuditLog(); + row.setDictionaryId(dictId); + row.setEventType(eventType); + row.setUserId(userId); + row.setEventTime(at); + row.setPayloadBefore(before); + row.setPayloadAfter(after); + return row; + } +} From 5d790ba7d56292e73c761420bed9a9960a9b96c0 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 11 May 2026 23:22:49 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(audit):=20definition=20action=20!=3D=20?= =?UTF-8?q?record=20action=20=E2=80=94=20fix=20SmokeE2ETest=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SmokeE2ETest.contextLoadsAndCreatesRecord фильтрует audit_log по action='CREATE' и ожидает row с businessKey. Мой backend #1 commit сделал definitionCreated() писать action='CREATE' — конфликт с record-level CREATE rows: filter подбирал dict-level row первой (no businessKey → null assertion fail). Fix: definitionCreated/definitionUpdated теперь пишут action 'DEFINITION_CREATE' / 'DEFINITION_UPDATE'. event_type остаётся DEFINITION_CREATED/UPDATED (changelog query filter по event_type IN список, не задеть). --- .../ordinis/restapi/audit/AuditLogger.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java index 3a5d866..17c2162 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java @@ -49,21 +49,22 @@ public class AuditLogger { } /** - * Audit для создания справочника. record-связанные поля null'ятся - * (record_id / business_key не применимы к dict-level event). - * payload_after = schema_json текущей версии. + * Audit для создания справочника. record-связанные поля null'ятся. + * + *

Action = {@code DEFINITION_CREATE} (не {@code CREATE}) — иначе + * существующие фильтры по action=CREATE подхватывают dict-level row + * без businessKey (SmokeE2ETest.contextLoadsAndCreatesRecord фейлится). */ public void definitionCreated(DictionaryDefinition d) { - write("DEFINITION_CREATED", "CREATE", d, null, null, d.getSchemaJson()); + write("DEFINITION_CREATED", "DEFINITION_CREATE", d, null, null, d.getSchemaJson()); } /** - * Audit для обновления schema справочника. payload_before — schema до - * update'а, payload_after — после. Используется для changelog / diff - * computation в frontend History tab. + * Audit для обновления schema справочника. payload_before/after — + * schema до/после. Используется changelog для diff computation. */ public void definitionUpdated(DictionaryDefinition d, JsonNode prevSchema) { - write("DEFINITION_UPDATED", "UPDATE", d, null, prevSchema, d.getSchemaJson()); + write("DEFINITION_UPDATED", "DEFINITION_UPDATE", d, null, prevSchema, d.getSchemaJson()); } public void recordUpdate(