Merge branch 'feat/dictionary-changelog' into 'main'
feat(api): GET /dictionaries/{name}/changelog — schema versions timeline (backend #1)
See merge request 2-6/2-6-4/terravault/ordinis!96
This commit is contained in:
+16
@@ -14,6 +14,22 @@ public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
|
|||||||
|
|
||||||
List<AuditLog> findByDictionaryIdOrderByEventTimeDesc(UUID dictionaryId);
|
List<AuditLog> 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<AuditLog> findByDictionaryIdAndEventTypes(
|
||||||
|
@Param("dictionaryId") UUID dictionaryId,
|
||||||
|
@Param("eventTypes") List<String> eventTypes,
|
||||||
|
Pageable pageable);
|
||||||
|
|
||||||
List<AuditLog> findByRecordIdOrderByEventTimeDesc(UUID recordId);
|
List<AuditLog> findByRecordIdOrderByEventTimeDesc(UUID recordId);
|
||||||
|
|
||||||
List<AuditLog> findByUserIdOrderByEventTimeDesc(String userId);
|
List<AuditLog> findByUserIdOrderByEventTimeDesc(String userId);
|
||||||
|
|||||||
+24
-1
@@ -48,6 +48,25 @@ public class AuditLogger {
|
|||||||
write("RECORD_CREATED", "CREATE", d, r, null, r.getData());
|
write("RECORD_CREATED", "CREATE", d, r, null, r.getData());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit для создания справочника. record-связанные поля null'ятся.
|
||||||
|
*
|
||||||
|
* <p>Action = {@code DEFINITION_CREATE} (не {@code CREATE}) — иначе
|
||||||
|
* существующие фильтры по action=CREATE подхватывают dict-level row
|
||||||
|
* без businessKey (SmokeE2ETest.contextLoadsAndCreatesRecord фейлится).
|
||||||
|
*/
|
||||||
|
public void definitionCreated(DictionaryDefinition d) {
|
||||||
|
write("DEFINITION_CREATED", "DEFINITION_CREATE", d, null, null, d.getSchemaJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit для обновления schema справочника. payload_before/after —
|
||||||
|
* schema до/после. Используется changelog для diff computation.
|
||||||
|
*/
|
||||||
|
public void definitionUpdated(DictionaryDefinition d, JsonNode prevSchema) {
|
||||||
|
write("DEFINITION_UPDATED", "DEFINITION_UPDATE", d, null, prevSchema, d.getSchemaJson());
|
||||||
|
}
|
||||||
|
|
||||||
public void recordUpdate(
|
public void recordUpdate(
|
||||||
DictionaryDefinition d, DictionaryRecord r, JsonNode payloadBefore) {
|
DictionaryDefinition d, DictionaryRecord r, JsonNode payloadBefore) {
|
||||||
write("RECORD_UPDATED", "UPDATE", d, r, payloadBefore, r.getData());
|
write("RECORD_UPDATED", "UPDATE", d, r, payloadBefore, r.getData());
|
||||||
@@ -71,8 +90,12 @@ public class AuditLogger {
|
|||||||
entry.setAction(action);
|
entry.setAction(action);
|
||||||
entry.setDictionaryId(d.getId());
|
entry.setDictionaryId(d.getId());
|
||||||
entry.setDictionaryName(d.getName());
|
entry.setDictionaryName(d.getName());
|
||||||
|
// Record-level events заполняют record_id / business_key; dict-level
|
||||||
|
// events (DEFINITION_*) проходят с r=null — оставляем NULL'ы.
|
||||||
|
if (r != null) {
|
||||||
entry.setRecordId(r.getId());
|
entry.setRecordId(r.getId());
|
||||||
entry.setBusinessKey(r.getBusinessKey());
|
entry.setBusinessKey(r.getBusinessKey());
|
||||||
|
}
|
||||||
entry.setPayloadBefore(before);
|
entry.setPayloadBefore(before);
|
||||||
entry.setPayloadAfter(after);
|
entry.setPayloadAfter(after);
|
||||||
|
|
||||||
@@ -92,7 +115,7 @@ public class AuditLogger {
|
|||||||
// алертилось. Compliance-кейс ловит на этой ошибке.
|
// алертилось. Compliance-кейс ловит на этой ошибке.
|
||||||
log.error(
|
log.error(
|
||||||
"Failed to write audit_log entry: dictionary={} record_id={} action={}: {}",
|
"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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+178
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>«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).
|
||||||
|
*
|
||||||
|
* <p>Diff computation — best-effort key-set comparison:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code added}: ключи которые есть в payload_after.properties но не в before</li>
|
||||||
|
* <li>{@code removed}: ключи в before но не в after</li>
|
||||||
|
* <li>{@code changed}: ключи в обоих, но JsonNode#equals == false</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Frontend строит human-readable строки из этих списков ("+altitude /
|
||||||
|
* ~operator"). Пустой diff (после=до) — мутация прошла но без property
|
||||||
|
* изменений (description/metadata), показываем "—" в UI.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ChangelogService {
|
||||||
|
|
||||||
|
private static final List<String> 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<AuditLog> rows = auditRepo.findByDictionaryIdAndEventTypes(
|
||||||
|
d.getId(), DEFINITION_EVENT_TYPES, PageRequest.of(0, safeLimit));
|
||||||
|
|
||||||
|
List<ChangelogEntry> 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, не используется сейчас).
|
||||||
|
*
|
||||||
|
* <p>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<String> beforeKeys = topLevelPropertyKeys(before);
|
||||||
|
Set<String> afterKeys = topLevelPropertyKeys(after);
|
||||||
|
|
||||||
|
List<String> added = new ArrayList<>();
|
||||||
|
List<String> removed = new ArrayList<>();
|
||||||
|
List<String> changed = new ArrayList<>();
|
||||||
|
|
||||||
|
Set<String> 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<String> 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<String> keys = new LinkedHashSet<>();
|
||||||
|
Iterator<Map.Entry<String, JsonNode>> 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<String> added,
|
||||||
|
List<String> removed,
|
||||||
|
List<String> changed) {}
|
||||||
|
|
||||||
|
/** Top-level response. */
|
||||||
|
public record ChangelogResponse(
|
||||||
|
String dictionary,
|
||||||
|
String currentVersion,
|
||||||
|
List<ChangelogEntry> entries) {}
|
||||||
|
}
|
||||||
+24
-1
@@ -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.DictionaryDefinitionCreated;
|
||||||
import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionUpdated;
|
import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionUpdated;
|
||||||
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
|
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.dto.CreateDictionaryRequest;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
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.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -19,11 +22,23 @@ public class DictionaryDefinitionService {
|
|||||||
|
|
||||||
private final DictionaryDefinitionRepository repository;
|
private final DictionaryDefinitionRepository repository;
|
||||||
private final OutboxRecorder outboxRecorder;
|
private final OutboxRecorder outboxRecorder;
|
||||||
|
/** Optional — None в tests/legacy конструкторе (без audit). */
|
||||||
|
private final AuditLogger auditLogger;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
public DictionaryDefinitionService(
|
public DictionaryDefinitionService(
|
||||||
DictionaryDefinitionRepository repository, OutboxRecorder outboxRecorder) {
|
DictionaryDefinitionRepository repository,
|
||||||
|
OutboxRecorder outboxRecorder,
|
||||||
|
AuditLogger auditLogger) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.outboxRecorder = outboxRecorder;
|
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)
|
@Transactional(readOnly = true)
|
||||||
@@ -99,6 +114,10 @@ public class DictionaryDefinitionService {
|
|||||||
"definition:" + saved.getName(),
|
"definition:" + saved.getName(),
|
||||||
envelope);
|
envelope);
|
||||||
|
|
||||||
|
if (auditLogger != null) {
|
||||||
|
auditLogger.definitionCreated(saved);
|
||||||
|
}
|
||||||
|
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +174,10 @@ public class DictionaryDefinitionService {
|
|||||||
"definition:" + saved.getName(),
|
"definition:" + saved.getName(),
|
||||||
envelope);
|
envelope);
|
||||||
|
|
||||||
|
if (auditLogger != null) {
|
||||||
|
auditLogger.definitionUpdated(saved, prevSchema);
|
||||||
|
}
|
||||||
|
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+46
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Source: audit_log filtered by DEFINITION_* event types. Каждая запись
|
||||||
|
* содержит payload_before/after = JsonNode schemas; ChangelogService
|
||||||
|
* computes property-level diff summary.
|
||||||
|
*
|
||||||
|
* <p>Errors:
|
||||||
|
* <ul>
|
||||||
|
* <li>404 {@code dictionary_not_found} — dict не существует или не виден
|
||||||
|
* caller'у (одинаковая ошибка чтобы не leak'ало existence).</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+197
@@ -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<DataScope> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user