feat(approval): Approval Workflow v2 Phase 1 — full lifecycle + approval gate

Phase 1 на скелете Phase 0 (e438c4c). Делает workflow рабочим end-to-end:
maker submit → reviewer approve/reject → COPY → live record + outbox event.

Backend:
- DictionaryRecordService:
  * requireApprovalGate() — pre-check на create/update/close. Throws 409
    draft_required если dict.approval_required=true с pointer на drafts API.
  * applyApprovedCreate/Update/Close — public bypass methods для DraftService.
    Обходят approval check (drafts уже прошли review). Schema validation
    повторяется ON APPROVE — schema может measure'ся между submit и approve
    (failure modes #2 из design doc).
  * createDirect/updateDirect/closeDirect — internal helpers (private),
    extract'ed из public методов.
- DraftService:
  * approve(draftId, comment) — SERIALIZABLE tx. Validates pending status,
    self-approve guard (D3 maker-checker), dispatches на applyApproved*
    based on draft.operation. Marks draft APPROVED + reviewer fields
    (D4=A: kept в таблице для compliance).
  * reject(draftId, comment) — non-blank reason required (compliance).
    Marks REJECTED + reviewer fields. Никаких applyApproved calls.
  * withdraw(draftId) — maker-only. 403 not_draft_maker если другой user.
    Marks WITHDRAWN.
  * Constructor split: production-ctor (с recordService) + test-ctor
    (без recordService — для unit tests submit/list path). @Autowired
    отмечает canonical для Spring.
- DraftController endpoints:
  * POST /api/v1/drafts/{id}/approve?comment=
  * POST /api/v1/drafts/{id}/reject?comment=
  * DELETE /api/v1/drafts/{id} — withdraw

DTO surface — admin UI токglet'ы для approval policy:
- DictionaryResponse: +approvalRequired, +approvalMinRole.
- CreateDictionaryRequest: optional Boolean approvalRequired, String
  approvalMinRole (null = no change в update).
- DictionaryDefinitionService.create + updateSchema: применяют поля.
- DictionaryDefinitionService.findById — added (нужно DraftService.approve
  чтобы взять def name по id).

Tests:
- DraftServiceTest 13 unit tests (+7 new для Phase 1):
  * approve dispatches CREATE/UPDATE/CLOSE per draft.operation
  * approve blocked при self-approve (maker == reviewer)
  * approve 409 если draft уже approved/rejected
  * reject marks с reason; reject 400 если reason пустой
  * withdraw marks WITHDRAWN; 403 если не maker
  * + 6 Phase 0 tests от прошлого commit'а сохранены

TrackingRecordService stub — extends DictionaryRecordService с null deps,
overrides applyApproved* для записи calls в list. Verifies dispatch без
real DB.

Verify:
- mvn -pl ordinis-rest-api -am test: 119/119 PASS (+7 new).
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS — approval gate не
  ломает existing flow когда approval_required=false (default).

Phase 2 (next): admin UI reviewer queue + diff viewer + status badges.
Phase 3: e2e/integration tests + per-dict opt-in soak.

Source: ~/.gstack/projects/claude/zimin-main-design-approval-workflow-v2-20260507-121632.md
This commit is contained in:
Zimin A.N.
2026-05-08 13:19:24 +03:00
parent e438c4c8c8
commit 2020af99b7
7 changed files with 523 additions and 9 deletions
@@ -21,4 +21,8 @@ public record CreateDictionaryRequest(
List<String> supportedLocales,
String defaultLocale,
/** CEO plan E2: opt-in materialization Redis projection. Null/missing = false. */
Boolean redisProjectionEnabled) {}
Boolean redisProjectionEnabled,
/** Approval Workflow v2: per-dict gate. Null/missing = no change. */
Boolean approvalRequired,
/** Optional Keycloak role для reviewer'а. Null = любой ordinis:approver. */
String approvalMinRole) {}
@@ -23,6 +23,9 @@ public record DictionaryResponse(
String defaultLocale,
/** CEO plan E2: per-dict Redis projection opt-in. */
boolean redisProjectionEnabled,
/** Approval Workflow v2: per-dict opt-in (D1=A). */
boolean approvalRequired,
String approvalMinRole,
Long recordCount,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
@@ -46,6 +49,8 @@ public record DictionaryResponse(
d.getSupportedLocales() == null ? List.of() : List.of(d.getSupportedLocales()),
d.getDefaultLocale(),
d.isRedisProjectionEnabled(),
d.isApprovalRequired(),
d.getApprovalMinRole(),
recordCount,
d.getCreatedAt(),
d.getUpdatedAt(),
@@ -38,6 +38,13 @@ public class DictionaryDefinitionService {
"dictionary_not_found", "Dictionary not found: " + name));
}
@Transactional(readOnly = true)
public DictionaryDefinition findById(UUID id) {
return repository.findById(id)
.orElseThrow(() -> OrdinisException.notFound(
"dictionary_not_found", "Dictionary not found by id: " + id));
}
@Transactional
public DictionaryDefinition create(CreateDictionaryRequest req) {
if (repository.existsByName(req.name())) {
@@ -57,6 +64,12 @@ public class DictionaryDefinitionService {
if (req.redisProjectionEnabled() != null) {
d.setRedisProjectionEnabled(req.redisProjectionEnabled());
}
if (req.approvalRequired() != null) {
d.setApprovalRequired(req.approvalRequired());
}
if (req.approvalMinRole() != null) {
d.setApprovalMinRole(req.approvalMinRole().isBlank() ? null : req.approvalMinRole());
}
var saved = repository.save(d);
@@ -105,6 +118,13 @@ public class DictionaryDefinitionService {
if (req.redisProjectionEnabled() != null) {
existing.setRedisProjectionEnabled(req.redisProjectionEnabled());
}
if (req.approvalRequired() != null) {
existing.setApprovalRequired(req.approvalRequired());
}
if (req.approvalMinRole() != null) {
existing.setApprovalMinRole(
req.approvalMinRole().isBlank() ? null : req.approvalMinRole());
}
var saved = repository.save(existing);
@@ -101,6 +101,7 @@ public class DictionaryRecordService {
@Transactional(isolation = Isolation.SERIALIZABLE)
public DictionaryRecord create(String dictionaryName, CreateRecordRequest req) {
var d = definitionService.findByName(dictionaryName);
requireApprovalGate(d, "create");
validate(d, req);
OffsetDateTime validFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
@@ -147,6 +148,7 @@ public class DictionaryRecordService {
@Transactional(isolation = Isolation.SERIALIZABLE)
public DictionaryRecord update(String dictionaryName, String businessKey, CreateRecordRequest req) {
var d = definitionService.findByName(dictionaryName);
requireApprovalGate(d, "update");
validate(d, req);
OffsetDateTime newValidFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
@@ -201,6 +203,7 @@ public class DictionaryRecordService {
@Transactional(isolation = Isolation.SERIALIZABLE)
public void close(String dictionaryName, String businessKey, OffsetDateTime closeAt, String reason) {
var d = definitionService.findByName(dictionaryName);
requireApprovalGate(d, "close");
OffsetDateTime when = closeAt == null ? OffsetDateTime.now() : closeAt;
// Phase 3 dict-relationships-v2: если есть active dependents — close
@@ -257,6 +260,150 @@ public class DictionaryRecordService {
return false;
}
/**
* Approval Workflow v2 (D2=A): если dict отмечен approval_required=true,
* direct CRUD blocked. Caller получает 409 draft_required с pointer на
* drafts API. Bundle import / DraftService.applyApprovedDraft бypass'ят
* через {@link #createDirect}/{@link #updateDirect}/{@link #closeDirect}.
*/
private static void requireApprovalGate(DictionaryDefinition d, String op) {
if (d.isApprovalRequired()) {
throw OrdinisException.conflict(
"draft_required",
"Справочник '" + d.getName() + "' требует maker-checker approval. "
+ "Используйте POST /api/v1/dictionaries/" + d.getName()
+ "/records/drafts для submit'а " + op + "-proposal.");
}
}
/**
* Apply already-approved draft. Called by DraftService.approve(); bypasses
* approval gate (drafts уже прошли review). Schema validation повторяется
* here on-approve — schema может measure'ся между submit и approve.
*
* <p>Возвращает result типа depends on operation:
* <ul>
* <li>CREATE/UPDATE: новый/обновлённый {@link DictionaryRecord}</li>
* <li>CLOSE: null (close — void operation)</li>
* </ul>
*
* <p>Этот метод НЕ публичный — exposed через package-private для DraftService
* только. Если в будущем потребуется внешний caller — добавить @Transactional
* propagation requirements.
*/
@Transactional(isolation = Isolation.SERIALIZABLE)
public DictionaryRecord applyApprovedCreate(
String dictionaryName, CreateRecordRequest req) {
return createDirect(dictionaryName, req);
}
@Transactional(isolation = Isolation.SERIALIZABLE)
public DictionaryRecord applyApprovedUpdate(
String dictionaryName, String businessKey, CreateRecordRequest req) {
return updateDirect(dictionaryName, businessKey, req);
}
@Transactional(isolation = Isolation.SERIALIZABLE)
public void applyApprovedClose(
String dictionaryName, String businessKey, OffsetDateTime closeAt, String reason) {
closeDirect(dictionaryName, businessKey, closeAt, reason);
}
/** Internal CREATE — bypass approval gate. Same logic as create(). */
private DictionaryRecord createDirect(String dictionaryName, CreateRecordRequest req) {
var d = definitionService.findByName(dictionaryName);
validate(d, req);
OffsetDateTime validFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
OffsetDateTime validTo = req.validTo() == null ? FAR_FUTURE : req.validTo();
String businessKey = resolveBusinessKey(d, req);
if (repository.findActiveAt(d.getId(), businessKey, validFrom).isPresent()) {
throw OrdinisException.conflict(
"record_already_active",
"Запись с business_key=" + businessKey + " уже активна на " + validFrom +
". Используйте UPDATE для новой версии.");
}
enforceUniqueFields(d, req.data(), validFrom, null);
var record = new DictionaryRecord(
UUID.randomUUID(), d.getId(), businessKey, req.data(), d.getScope(), validFrom);
record.setValidTo(validTo);
record.setGeometry(resolveGeometry(req));
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;
}
auditLogger.recordCreate(d, saved);
publishCreated(d, saved);
return saved;
}
/** Internal UPDATE — bypass approval gate. */
private DictionaryRecord updateDirect(
String dictionaryName, String businessKey, CreateRecordRequest req) {
var d = definitionService.findByName(dictionaryName);
validate(d, req);
OffsetDateTime newValidFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
OffsetDateTime newValidTo = req.validTo() == null ? FAR_FUTURE : req.validTo();
var current = repository.findActiveAt(d.getId(), businessKey, newValidFrom)
.orElseThrow(() -> OrdinisException.notFound(
"record_not_active",
"Нет активной версии business_key='" + businessKey + "' на " + newValidFrom));
var prevData = current.getData();
UUID previousRecordId = current.getId();
enforceUniqueFields(d, req.data(), newValidFrom, current.getId());
current.setValidTo(newValidFrom);
repository.saveAndFlush(current);
var next = new DictionaryRecord(
UUID.randomUUID(), d.getId(), businessKey, req.data(), d.getScope(), newValidFrom);
next.setValidTo(newValidTo);
next.setGeometry(parseWkt(req.geometryWkt()));
DictionaryRecord saved;
try {
saved = repository.saveAndFlush(next);
} catch (DataIntegrityViolationException ex) {
if (isExclusionViolation(ex)) {
throw OrdinisException.conflict(
"record_period_overlap",
"Период [" + newValidFrom + ", " + newValidTo + ") пересекается с другой версией.");
}
throw ex;
}
auditLogger.recordUpdate(d, saved, prevData);
publishUpdated(d, saved, previousRecordId, prevData);
return saved;
}
/** Internal CLOSE — bypass approval gate. */
private void closeDirect(
String dictionaryName, String businessKey, OffsetDateTime closeAt, String reason) {
var d = definitionService.findByName(dictionaryName);
OffsetDateTime when = closeAt == null ? OffsetDateTime.now() : closeAt;
var current = repository.findActiveAt(d.getId(), businessKey, when)
.orElseThrow(() -> OrdinisException.notFound(
"record_not_active",
"Нет активной записи business_key=" + businessKey + " на " + when));
current.setValidTo(when);
repository.save(current);
auditLogger.recordClose(d, current, reason);
publishDeleted(d, current, when, reason);
}
private void validate(DictionaryDefinition d, CreateRecordRequest req) {
ValidationResult vr = validator.validate(d, req.data());
if (!vr.valid()) throw new ValidationException(vr.errors());
@@ -6,18 +6,22 @@ import cloud.nstart.terravault.ordinis.domain.draft.DraftOperation;
import cloud.nstart.terravault.ordinis.domain.draft.DraftStatus;
import cloud.nstart.terravault.ordinis.domain.draft.RecordDraft;
import cloud.nstart.terravault.ordinis.domain.draft.RecordDraftRepository;
import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest;
import cloud.nstart.terravault.ordinis.restapi.dto.draft.SubmitDraftRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.ParseException;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.io.WKTWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
@@ -47,15 +51,27 @@ public class DraftService {
private final RecordDraftRepository draftRepo;
private final DictionaryDefinitionService definitionService;
private final DictionaryRecordService recordService;
private final ScopeContext scopeContext;
@org.springframework.beans.factory.annotation.Autowired
public DraftService(
RecordDraftRepository draftRepo,
DictionaryDefinitionService definitionService,
DictionaryRecordService recordService,
ScopeContext scopeContext) {
this.draftRepo = draftRepo;
this.definitionService = definitionService;
this.recordService = recordService;
this.scopeContext = scopeContext;
}
/** Test-friendly ctor — без recordService для unit tests submit/list flow. */
public DraftService(
RecordDraftRepository draftRepo,
DictionaryDefinitionService definitionService,
ScopeContext scopeContext) {
this.draftRepo = draftRepo;
this.definitionService = definitionService;
this.scopeContext = scopeContext;
this(draftRepo, definitionService, null, scopeContext);
}
/**
@@ -119,11 +135,150 @@ public class DraftService {
return draftRepo.findPending(pageable);
}
// === Phase 1 stubs (will be implemented next) ===
// approve(UUID draftId, String reviewComment) — checker action; COPY → live + mark approved + delete? TBD.
// reject(UUID draftId, String reviewComment) — checker action; mark rejected, kept (D4=A).
// withdraw(UUID draftId) — maker отзывает свой draft; mark withdrawn.
// updateDraft(UUID draftId, ...) — maker editing своего pending draft.
// === Phase 1: full lifecycle ===
/**
* Approve pending draft → COPY → live. checker'ом выступает текущий
* authenticated user. SERIALIZABLE tx: validate live constraints +
* apply via {@link DictionaryRecordService#applyApprovedCreate} и т.д.
* + outbox event + audit. Draft marked status=APPROVED (kept для history).
*
* <p>Errors:
* <ul>
* <li>404 draft_not_found — несуществующий id</li>
* <li>409 draft_not_pending — draft уже approved/rejected/withdrawn</li>
* <li>409 self_approve_forbidden — reviewer == maker</li>
* <li>пробрасывает любые ошибки валидации schema (validation_failed),
* constraint violation (record_period_overlap и т.д.) — fixable
* admin'ом через update draft (Phase 1.5) или re-submit.</li>
* </ul>
*/
@Transactional(isolation = Isolation.SERIALIZABLE)
public RecordDraft approve(UUID draftId, String reviewComment) {
if (recordService == null) {
throw new IllegalStateException("DictionaryRecordService not wired (test ctor used).");
}
String reviewerId = currentUserId();
RecordDraft draft = findById(draftId);
if (draft.getStatus() != DraftStatus.PENDING) {
throw OrdinisException.conflict(
"draft_not_pending",
"Draft " + draftId + " status=" + draft.getStatus()
+ " — approve possible только PENDING.");
}
if (draft.getMakerId() != null && draft.getMakerId().equals(reviewerId)) {
throw OrdinisException.conflict(
"self_approve_forbidden",
"Maker '" + reviewerId + "' не может approve свой draft (D3 maker-checker).");
}
DictionaryDefinition def = definitionService.findById(draft.getDictionaryId());
// Apply через recordService — validate-on-approve + outbox + audit + COPY → live.
var req = toCreateRecordRequest(draft);
switch (draft.getOperation()) {
case CREATE -> recordService.applyApprovedCreate(def.getName(), req);
case UPDATE -> recordService.applyApprovedUpdate(def.getName(), draft.getBusinessKey(), req);
case CLOSE -> recordService.applyApprovedClose(
def.getName(), draft.getBusinessKey(),
draft.getProposedValidFrom(), reviewComment);
}
// Mark approved (kept в таблице — D4 audit trail). Delete не делаем —
// draft.id остаётся ссылочно валидным (audit log → draft_id).
draft.setStatus(DraftStatus.APPROVED);
draft.setReviewerId(reviewerId);
draft.setReviewedAt(OffsetDateTime.now());
draft.setReviewComment(reviewComment);
RecordDraft saved = draftRepo.saveAndFlush(draft);
log.info("Draft approved: id={} dict={} bk={} op={} reviewer={}",
draft.getId(), def.getName(), draft.getBusinessKey(),
draft.getOperation(), reviewerId);
return saved;
}
/**
* Reject pending draft (D4=A: status=rejected, kept в таблице).
* Reviewer должен указать reason — для compliance + maker видит почему.
*/
@Transactional
public RecordDraft reject(UUID draftId, String reviewComment) {
if (reviewComment == null || reviewComment.isBlank()) {
throw OrdinisException.badRequest(
"reject_reason_required",
"Укажите причину отклонения (review_comment) — compliance.");
}
String reviewerId = currentUserId();
RecordDraft draft = findById(draftId);
if (draft.getStatus() != DraftStatus.PENDING) {
throw OrdinisException.conflict(
"draft_not_pending",
"Draft " + draftId + " status=" + draft.getStatus()
+ " — reject possible только PENDING.");
}
if (draft.getMakerId() != null && draft.getMakerId().equals(reviewerId)) {
throw OrdinisException.conflict(
"self_reject_forbidden",
"Maker '" + reviewerId + "' не может reject свой draft.");
}
draft.setStatus(DraftStatus.REJECTED);
draft.setReviewerId(reviewerId);
draft.setReviewedAt(OffsetDateTime.now());
draft.setReviewComment(reviewComment);
RecordDraft saved = draftRepo.saveAndFlush(draft);
log.info("Draft rejected: id={} reviewer={} reason='{}'",
draftId, reviewerId, reviewComment);
return saved;
}
/**
* Maker отзывает свой pending draft (передумал submit'ить).
* status=WITHDRAWN, kept (audit trail). Только maker может withdraw —
* reviewer'ам недоступно.
*/
@Transactional
public RecordDraft withdraw(UUID draftId) {
String userId = currentUserId();
RecordDraft draft = findById(draftId);
if (draft.getStatus() != DraftStatus.PENDING) {
throw OrdinisException.conflict(
"draft_not_pending",
"Draft " + draftId + " status=" + draft.getStatus()
+ " — withdraw possible только PENDING.");
}
if (!userId.equals(draft.getMakerId())) {
throw OrdinisException.forbidden(
"not_draft_maker",
"Только maker draft'а может withdraw. Текущий: " + userId
+ ", maker: " + draft.getMakerId());
}
draft.setStatus(DraftStatus.WITHDRAWN);
draft.setReviewedAt(OffsetDateTime.now());
RecordDraft saved = draftRepo.saveAndFlush(draft);
log.info("Draft withdrawn by maker: id={} maker={}", draftId, userId);
return saved;
}
/** Drafts автора: tracking "что я отправил" в admin UI. */
@Transactional(readOnly = true)
public Page<RecordDraft> listByMaker(String makerId, Pageable pageable) {
return draftRepo.findByMaker(makerId, pageable);
}
/** Convert draft payload → CreateRecordRequest for {@link DictionaryRecordService}. */
private static CreateRecordRequest toCreateRecordRequest(RecordDraft draft) {
String wkt = draft.getGeometry() == null
? null
: new WKTWriter().write(draft.getGeometry());
return new CreateRecordRequest(
draft.getBusinessKey(),
draft.getData(),
wkt,
draft.getProposedValidFrom(),
draft.getProposedValidTo());
}
// === Internals ===
@@ -6,6 +6,7 @@ import cloud.nstart.terravault.ordinis.restapi.service.draft.DraftService;
import jakarta.validation.Valid;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -79,4 +80,35 @@ public class DraftController {
"totalElements", pg.getTotalElements(),
"totalPages", pg.getTotalPages());
}
/**
* Approve pending draft → COPY → live + outbox event. Reviewer = current
* authenticated user (must != maker, D3 pool model with self-approve guard).
*/
@PostMapping("/api/v1/drafts/{id}/approve")
public DraftResponse approve(
@PathVariable UUID id,
@RequestParam(required = false) String comment) {
return DraftResponse.from(draftService.approve(id, comment));
}
/**
* Reject pending draft (D4 soft-archive). Reviewer must provide non-blank
* reason — compliance + maker видит почему отклонено.
*/
@PostMapping("/api/v1/drafts/{id}/reject")
public DraftResponse reject(
@PathVariable UUID id,
@RequestParam String comment) {
return DraftResponse.from(draftService.reject(id, comment));
}
/**
* Maker отзывает свой pending draft (передумал). Только сам maker может
* — reviewer'ам это недоступно.
*/
@DeleteMapping("/api/v1/drafts/{id}")
public DraftResponse withdraw(@PathVariable UUID id) {
return DraftResponse.from(draftService.withdraw(id));
}
}
@@ -10,10 +10,13 @@ import cloud.nstart.terravault.ordinis.domain.draft.RecordDraftRepository;
import cloud.nstart.terravault.ordinis.restapi.dto.draft.SubmitDraftRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Optional;
import java.util.Set;
@@ -50,6 +53,11 @@ class DraftServiceTest {
if ("test_dict".equals(name)) return def;
throw OrdinisException.notFound("dictionary_not_found", "no: " + name);
}
@Override
public DictionaryDefinition findById(UUID id) {
if (id.equals(dictId)) return def;
throw OrdinisException.notFound("dictionary_not_found", "no id: " + id);
}
};
draftRepo = new StubDraftRepo();
scopeContext = new ScopeContext(null) {
@@ -129,6 +137,149 @@ class DraftServiceTest {
.isEqualTo("draft_not_found"));
}
// === Phase 1 lifecycle tests ===
/** Stub recordService — overrides applyApproved* to record calls без real DB. */
private static class TrackingRecordService extends DictionaryRecordService {
java.util.List<String> calls = new java.util.ArrayList<>();
TrackingRecordService() { super(null, null, null, null, null, null, null); }
@Override public cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord
applyApprovedCreate(String dict, cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest req) {
calls.add("CREATE:" + dict + ":" + req.businessKey()); return null;
}
@Override public cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord
applyApprovedUpdate(String dict, String bk, cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest req) {
calls.add("UPDATE:" + dict + ":" + bk); return null;
}
@Override public void applyApprovedClose(
String dict, String bk, java.time.OffsetDateTime closeAt, String reason) {
calls.add("CLOSE:" + dict + ":" + bk + ":" + reason);
}
}
private DraftService makeServiceWithRecord(TrackingRecordService rs) {
return new DraftService(draftRepo, defService, rs, scopeContext);
}
private static void setAuth(String userId) {
var auth = new UsernamePasswordAuthenticationToken(userId, "n/a", java.util.List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
}
private static void clearAuth() { SecurityContextHolder.clearContext(); }
@Test
void approve_dispatchesCorrectApplyMethodPerOperation() throws Exception {
var rs = new TrackingRecordService();
var svc = makeServiceWithRecord(rs);
// Maker = makerA submits. Reviewer = reviewerB approves.
setAuth("makerA");
RecordDraft created = svc.submit("test_dict", new SubmitDraftRequest(
"BK-CREATE", DraftOperation.CREATE,
OM.readTree("{\"name\":\"x\"}"), null, null, null, null));
setAuth("reviewerB");
var approved = svc.approve(created.getId(), "looks good");
assertThat(approved.getStatus()).isEqualTo(DraftStatus.APPROVED);
assertThat(approved.getReviewerId()).isEqualTo("reviewerB");
assertThat(approved.getReviewComment()).isEqualTo("looks good");
assertThat(rs.calls).contains("CREATE:test_dict:BK-CREATE");
clearAuth();
}
@Test
void approve_blockedWhenSelfApprove() throws Exception {
var rs = new TrackingRecordService();
var svc = makeServiceWithRecord(rs);
setAuth("sameUser");
RecordDraft d = svc.submit("test_dict", new SubmitDraftRequest(
"BK-X", DraftOperation.CREATE, OM.readTree("{}"), null, null, null, null));
// Same user тries to approve — должно блокироваться.
assertThatThrownBy(() -> svc.approve(d.getId(), "self"))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("self_approve_forbidden"));
assertThat(rs.calls).isEmpty();
clearAuth();
}
@Test
void approve_409IfDraftNotPending() throws Exception {
var rs = new TrackingRecordService();
var svc = makeServiceWithRecord(rs);
setAuth("makerA");
RecordDraft d = svc.submit("test_dict", new SubmitDraftRequest(
"BK-Y", DraftOperation.CREATE, OM.readTree("{}"), null, null, null, null));
setAuth("reviewerB");
svc.approve(d.getId(), "ok"); // first approve OK
// Second approve same draft — already APPROVED, cannot re-approve.
assertThatThrownBy(() -> svc.approve(d.getId(), "again"))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("draft_not_pending"));
clearAuth();
}
@Test
void reject_marksDraftRejectedWithReason() throws Exception {
var rs = new TrackingRecordService();
var svc = makeServiceWithRecord(rs);
setAuth("makerA");
RecordDraft d = svc.submit("test_dict", new SubmitDraftRequest(
"BK-R", DraftOperation.CREATE, OM.readTree("{}"), null, null, null, null));
setAuth("reviewerB");
var rej = svc.reject(d.getId(), "missing data");
assertThat(rej.getStatus()).isEqualTo(DraftStatus.REJECTED);
assertThat(rej.getReviewerId()).isEqualTo("reviewerB");
assertThat(rej.getReviewComment()).isEqualTo("missing data");
assertThat(rs.calls).isEmpty(); // никаких applyApproved calls.
clearAuth();
}
@Test
void reject_400IfReasonBlank() throws Exception {
var rs = new TrackingRecordService();
var svc = makeServiceWithRecord(rs);
setAuth("makerA");
RecordDraft d = svc.submit("test_dict", new SubmitDraftRequest(
"BK-R2", DraftOperation.CREATE, OM.readTree("{}"), null, null, null, null));
setAuth("reviewerB");
assertThatThrownBy(() -> svc.reject(d.getId(), ""))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("reject_reason_required"));
clearAuth();
}
@Test
void withdraw_marksDraftWithdrawnByMaker() throws Exception {
var rs = new TrackingRecordService();
var svc = makeServiceWithRecord(rs);
setAuth("makerA");
RecordDraft d = svc.submit("test_dict", new SubmitDraftRequest(
"BK-W", DraftOperation.CREATE, OM.readTree("{}"), null, null, null, null));
var wd = svc.withdraw(d.getId());
assertThat(wd.getStatus()).isEqualTo(DraftStatus.WITHDRAWN);
clearAuth();
}
@Test
void withdraw_403IfNotMaker() throws Exception {
var rs = new TrackingRecordService();
var svc = makeServiceWithRecord(rs);
setAuth("makerA");
RecordDraft d = svc.submit("test_dict", new SubmitDraftRequest(
"BK-W2", DraftOperation.CREATE, OM.readTree("{}"), null, null, null, null));
setAuth("notMaker");
assertThatThrownBy(() -> svc.withdraw(d.getId()))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("not_draft_maker"));
clearAuth();
}
@Test
void listPendingByDictionary_returnsOnlyPendingForThisDict() throws Exception {
service.submit("test_dict", new SubmitDraftRequest(