diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateDictionaryRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateDictionaryRequest.java index cf79ec0..8c82557 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateDictionaryRequest.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/CreateDictionaryRequest.java @@ -21,4 +21,8 @@ public record CreateDictionaryRequest( List 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) {} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/DictionaryResponse.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/DictionaryResponse.java index 1aba003..2438e5a 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/DictionaryResponse.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/DictionaryResponse.java @@ -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(), 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 26590cf..3a375f7 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 @@ -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); diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java index 299abea..1d5c800 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java @@ -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. + * + *

Возвращает result типа depends on operation: + *

+ * + *

Этот метод НЕ публичный — 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()); diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java index 2c4c48f..574cdf9 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java @@ -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). + * + *

Errors: + *

+ */ + @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 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 === diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DraftController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DraftController.java index 1a2aa11..acd3a96 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DraftController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DraftController.java @@ -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)); + } } diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java index e014925..f99bfd6 100644 --- a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java @@ -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 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(