diff --git a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/ApprovalWorkflowE2ETest.java b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/ApprovalWorkflowE2ETest.java
new file mode 100644
index 0000000..bd725d1
--- /dev/null
+++ b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/ApprovalWorkflowE2ETest.java
@@ -0,0 +1,382 @@
+package cloud.nstart.terravault.ordinis.app.e2e;
+
+import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository;
+import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Approval Workflow v2 — Phase 3 e2e coverage.
+ *
+ *
Полный maker-checker lifecycle на реальном Postgres + Spring Security
+ * (JWT через {@link JwtTestSupport}). Maker и reviewer = разные subjects → проверяем
+ * self-approve guard, exclusion constraint, regression на dict без approval_required,
+ * outbox event после approve.
+ *
+ *
Coverage matrix (соответствует Phase 3 чеклисту в design doc):
+ *
+ * - {@link #happyFlow_submitApprove_recordLiveAndOutboxEvent} — full maker→checker
+ * - {@link #rejectFlow_keepsDraft_noLiveRecord} — reject path
+ * - {@link #selfApproveBlocked_409} — no_self_approve constraint
+ * - {@link #twoPendingDraftsForSameBusinessKey_blocked} — one_pending_per_key
+ * - {@link #directCrudBlockedWhenApprovalRequired} — D2=A gate (409 draft_required)
+ * - {@link #regression_approvalNotRequired_directCrudStillWorks} — non-regression
+ * - {@link #withdrawByMaker_keepsDraftStatus} — maker withdraw
+ * - {@link #rejectWithoutComment_400} — reject_reason_required
+ *
+ */
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+class ApprovalWorkflowE2ETest {
+
+ @DynamicPropertySource
+ static void props(DynamicPropertyRegistry registry) {
+ E2ESupport.registerProperties(registry);
+ // JWT auth ON — для self-approve guard нужны разные subjects.
+ registry.add("ordinis.auth.jwk-set-uri", JwtTestSupport::jwksUrl);
+ registry.add("ordinis.auth.require-authentication", () -> "true");
+ registry.add("ordinis.auth.allow-query-scope", () -> "false");
+ registry.add("ordinis.auth.issuer-uri", () -> "");
+ }
+
+ @Autowired MockMvc mvc;
+ @Autowired ObjectMapper om;
+ @Autowired DictionaryRecordRepository recordRepo;
+ @Autowired OutboxEventRepository outboxRepo;
+
+ // === Helpers ===
+
+ /** Internal-scope JWT для конкретного юзера. */
+ private static String tokenFor(String subject) {
+ return JwtTestSupport.signJwt(List.of("ordinis:client:internal"), subject);
+ }
+
+ private static final String SCHEMA_JSON = """
+ {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "x-id-source": "code",
+ "required": ["code", "label"],
+ "properties": {
+ "code": { "type": "string", "x-unique": true },
+ "label": { "type": "string", "minLength": 1 }
+ }
+ }
+ """;
+
+ private String createDict(String token, String dictName, boolean approvalRequired) throws Exception {
+ JsonNode schema = om.readTree(SCHEMA_JSON);
+ var body = om.createObjectNode()
+ .put("name", dictName)
+ .put("scope", "INTERNAL")
+ .put("schemaVersion", "1.0.0")
+ .put("bundle", "test")
+ .put("approvalRequired", approvalRequired);
+ body.set("schemaJson", schema);
+ mvc.perform(post("/api/v1/dictionaries")
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(om.writeValueAsBytes(body)))
+ .andExpect(status().is2xxSuccessful());
+ return dictName;
+ }
+
+ /**
+ * Создать dict без approval, затем через PUT включить approval_required.
+ * Тестируем path "включили approval после создания".
+ */
+ private String createDictThenEnableApproval(String token, String dictName) throws Exception {
+ createDict(token, dictName, false);
+ JsonNode schema = om.readTree(SCHEMA_JSON);
+ var body = om.createObjectNode()
+ .put("name", dictName)
+ .put("scope", "INTERNAL")
+ .put("schemaVersion", "1.0.0")
+ .put("bundle", "test")
+ .put("approvalRequired", true);
+ body.set("schemaJson", schema);
+ mvc.perform(put("/api/v1/dictionaries/{name}", dictName)
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(om.writeValueAsBytes(body)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.approvalRequired").value(true));
+ return dictName;
+ }
+
+ private static String randDictName(String prefix) {
+ return prefix + "_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
+ }
+
+ private static String randBk() {
+ return "BK_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase();
+ }
+
+ private JsonNode submitDraft(String token, String dictName, String bk, String label) throws Exception {
+ var data = om.createObjectNode().put("code", bk).put("label", label);
+ var body = om.createObjectNode().put("businessKey", bk).put("operation", "CREATE");
+ body.set("data", data);
+ MvcResult res = mvc.perform(post("/api/v1/dictionaries/{n}/records/drafts", dictName)
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(om.writeValueAsBytes(body)))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.status").value("PENDING"))
+ .andReturn();
+ return om.readTree(res.getResponse().getContentAsString());
+ }
+
+ // === Tests ===
+
+ @Test
+ void happyFlow_submitApprove_recordLiveAndOutboxEvent() throws Exception {
+ String makerToken = tokenFor("alice-maker");
+ String reviewerToken = tokenFor("bob-reviewer");
+
+ String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_h"));
+ String bk = randBk();
+ long outboxBefore = outboxRepo.count();
+
+ // 1. Maker submits CREATE draft.
+ JsonNode draft = submitDraft(makerToken, dictName, bk, "Alpha");
+ String draftId = draft.get("id").asText();
+ assertThat(draft.get("makerId").asText()).isEqualTo("alice-maker");
+
+ // 2. Until approved — НЕТ live записи.
+ long liveBefore = recordRepo.findAll().stream()
+ .filter(r -> bk.equals(r.getBusinessKey())).count();
+ assertThat(liveBefore).isZero();
+
+ // 3. Reviewer approves.
+ mvc.perform(post("/api/v1/drafts/{id}/approve", draftId)
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + reviewerToken)
+ .param("comment", "LGTM"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.status").value("APPROVED"))
+ .andExpect(jsonPath("$.reviewerId").value("bob-reviewer"))
+ .andExpect(jsonPath("$.reviewComment").value("LGTM"));
+
+ // 4. Live record persisted.
+ var liveRecs = recordRepo.findAll().stream()
+ .filter(r -> bk.equals(r.getBusinessKey()))
+ .toList();
+ assertThat(liveRecs)
+ .as("approved CREATE → 1 live record")
+ .hasSize(1);
+
+ // 5. Outbox event записан (RecordCreated). countAll вырос.
+ assertThat(outboxRepo.count())
+ .as("approve должен породить outbox event")
+ .isGreaterThan(outboxBefore);
+
+ // 6. Reader endpoint видит запись (sanity — reviewer scope INTERNAL).
+ mvc.perform(get("/api/v1/dictionaries/{n}/records/{k}", dictName, bk)
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + reviewerToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.businessKey").value(bk))
+ .andExpect(jsonPath("$.data.label").value("Alpha"));
+ }
+
+ @Test
+ void rejectFlow_keepsDraft_noLiveRecord() throws Exception {
+ String makerToken = tokenFor("carol-maker");
+ String reviewerToken = tokenFor("dave-reviewer");
+
+ String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_rej"));
+ String bk = randBk();
+
+ JsonNode draft = submitDraft(makerToken, dictName, bk, "Beta");
+ String draftId = draft.get("id").asText();
+
+ // Reject с reason — должен пройти.
+ mvc.perform(post("/api/v1/drafts/{id}/reject", draftId)
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + reviewerToken)
+ .param("comment", "Не соответствует регламенту"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.status").value("REJECTED"))
+ .andExpect(jsonPath("$.reviewComment").value("Не соответствует регламенту"));
+
+ // Live record НЕТ.
+ var liveRecs = recordRepo.findAll().stream()
+ .filter(r -> bk.equals(r.getBusinessKey()))
+ .toList();
+ assertThat(liveRecs).as("rejected draft не публикует live record").isEmpty();
+
+ // Re-approve того же draft → 409 draft_not_pending (status !=PENDING).
+ mvc.perform(post("/api/v1/drafts/{id}/approve", draftId)
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + reviewerToken))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.code").value("draft_not_pending"));
+ }
+
+ @Test
+ void selfApproveBlocked_409() throws Exception {
+ String token = tokenFor("eve-solo");
+ String dictName = createDictThenEnableApproval(token, randDictName("appr_self"));
+ String bk = randBk();
+
+ JsonNode draft = submitDraft(token, dictName, bk, "Gamma");
+ String draftId = draft.get("id").asText();
+
+ // Тот же subject пробует approve свой draft.
+ mvc.perform(post("/api/v1/drafts/{id}/approve", draftId)
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + token))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.code").value("self_approve_forbidden"));
+
+ // Reject себя — также блок.
+ mvc.perform(post("/api/v1/drafts/{id}/reject", draftId)
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
+ .param("comment", "self-reject test"))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.code").value("self_reject_forbidden"));
+ }
+
+ @Test
+ void twoPendingDraftsForSameBusinessKey_blocked() throws Exception {
+ String makerToken = tokenFor("frank-maker");
+ String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_excl"));
+ String bk = randBk();
+
+ submitDraft(makerToken, dictName, bk, "First proposal");
+
+ // Второй submit на тот же business_key — exclusion constraint → 409.
+ var data2 = om.createObjectNode().put("code", bk).put("label", "Second");
+ var body2 = om.createObjectNode().put("businessKey", bk).put("operation", "CREATE");
+ body2.set("data", data2);
+ mvc.perform(post("/api/v1/dictionaries/{n}/records/drafts", dictName)
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + makerToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(om.writeValueAsBytes(body2)))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.code").value("draft_already_pending"));
+ }
+
+ @Test
+ void directCrudBlockedWhenApprovalRequired() throws Exception {
+ String token = tokenFor("grace-admin");
+ String dictName = createDictThenEnableApproval(token, randDictName("appr_gate"));
+ String bk = randBk();
+
+ var recBody = om.createObjectNode().put("businessKey", bk);
+ recBody.set("data", om.createObjectNode().put("code", bk).put("label", "X"));
+
+ // Direct POST → 409 draft_required (D2=A).
+ mvc.perform(post("/api/v1/dictionaries/{n}/records", dictName)
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(om.writeValueAsBytes(recBody)))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.code").value("draft_required"));
+
+ // Direct PUT тоже блокируется. Сначала надо создать запись через approval
+ // path — но проще: PUT по несуществующему ключу вернёт draft_required
+ // ДО проверки existence (gate стоит первым в service).
+ mvc.perform(put("/api/v1/dictionaries/{n}/records/{k}", dictName, bk)
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(om.writeValueAsBytes(recBody)))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.code").value("draft_required"));
+
+ // DELETE тоже блокируется approval gate'ом.
+ mvc.perform(delete("/api/v1/dictionaries/{n}/records/{k}", dictName, bk)
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + token))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.code").value("draft_required"));
+ }
+
+ @Test
+ void regression_approvalNotRequired_directCrudStillWorks() throws Exception {
+ String token = tokenFor("henry-admin");
+ String dictName = createDict(token, randDictName("appr_reg"), false);
+ String bk = randBk();
+
+ // Direct POST на dict БЕЗ approval — работает как до Approval v2.
+ var recBody = om.createObjectNode().put("businessKey", bk);
+ recBody.set("data", om.createObjectNode().put("code", bk).put("label", "Regression OK"));
+ mvc.perform(post("/api/v1/dictionaries/{n}/records", dictName)
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(om.writeValueAsBytes(recBody)))
+ .andExpect(status().is2xxSuccessful())
+ .andExpect(jsonPath("$.businessKey").value(bk));
+
+ // GET возвращает запись.
+ mvc.perform(get("/api/v1/dictionaries/{n}/records/{k}", dictName, bk)
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + token))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.label").value("Regression OK"));
+ }
+
+ @Test
+ void withdrawByMaker_keepsDraftStatus() throws Exception {
+ String makerToken = tokenFor("ivan-maker");
+ String reviewerToken = tokenFor("julia-reviewer");
+
+ String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_wd"));
+ String bk = randBk();
+
+ JsonNode draft = submitDraft(makerToken, dictName, bk, "Delta");
+ String draftId = draft.get("id").asText();
+
+ // Reviewer пробует withdraw — 403 not_draft_maker.
+ mvc.perform(delete("/api/v1/drafts/{id}", draftId)
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + reviewerToken))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value("not_draft_maker"));
+
+ // Maker withdraws — 200 + status=WITHDRAWN.
+ mvc.perform(delete("/api/v1/drafts/{id}", draftId)
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + makerToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.status").value("WITHDRAWN"));
+
+ // После withdraw — re-submit на тот же business_key РАБОТАЕТ (exclusion
+ // снят, status более не PENDING).
+ submitDraft(makerToken, dictName, bk, "Delta v2");
+ }
+
+ @Test
+ void rejectWithoutComment_400() throws Exception {
+ String makerToken = tokenFor("kate-maker");
+ String reviewerToken = tokenFor("leo-reviewer");
+
+ String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_rc"));
+ String bk = randBk();
+
+ JsonNode draft = submitDraft(makerToken, dictName, bk, "Epsilon");
+ String draftId = draft.get("id").asText();
+
+ // Reject без comment — 400 reject_reason_required.
+ mvc.perform(post("/api/v1/drafts/{id}/reject", draftId)
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + reviewerToken)
+ .param("comment", ""))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value("reject_reason_required"));
+ }
+}
diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0020-fix-draft-status-case.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0020-fix-draft-status-case.xml
new file mode 100644
index 0000000..e946d3e
--- /dev/null
+++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0020-fix-draft-status-case.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+ Normalize any prior lowercase status values to UPPERCASE before recreate.
+ UPDATE record_drafts SET status = UPPER(status) WHERE status != UPPER(status);
+
+
+
+ Recreate status check constraint with UPPERCASE values matching enum.
+ ALTER TABLE record_drafts DROP CONSTRAINT IF EXISTS record_drafts_status_check;
+
+ ALTER TABLE record_drafts
+ ADD CONSTRAINT record_drafts_status_check
+ CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED', 'WITHDRAWN'));
+
+
+
+
+ Recreate exclusion WHERE clause to use 'PENDING' (matches enum serialization).
+ ALTER TABLE record_drafts DROP CONSTRAINT IF EXISTS record_drafts_one_pending_per_key;
+
+ ALTER TABLE record_drafts
+ ADD CONSTRAINT record_drafts_one_pending_per_key
+ EXCLUDE USING btree (
+ dictionary_id WITH =,
+ business_key WITH =
+ ) WHERE (status = 'PENDING');
+
+
+
+
diff --git a/ordinis-migrations/src/main/resources/db/changelog/master.xml b/ordinis-migrations/src/main/resources/db/changelog/master.xml
index 7b96e57..d8f77cf 100644
--- a/ordinis-migrations/src/main/resources/db/changelog/master.xml
+++ b/ordinis-migrations/src/main/resources/db/changelog/master.xml
@@ -29,5 +29,6 @@
+
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 574cdf9..146fb3f 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
@@ -110,13 +110,31 @@ public class DraftService {
dictionaryName, req.businessKey(), req.operation(), makerId);
return saved;
} catch (DataIntegrityViolationException ex) {
- // record_drafts_one_pending_per_key — exclusion violation.
- throw OrdinisException.conflict(
- "draft_already_pending",
- "На запись business_key=" + req.businessKey() + " уже есть pending draft.");
+ // SQLSTATE 23P01 = exclusion_violation (record_drafts_one_pending_per_key).
+ // Все остальные DataIntegrity (check, FK, NOT NULL) пробрасываем как-есть —
+ // иначе скрываем баги схемы (Phase 3 lessons learned: enum case mismatch
+ // прятался в маске draft_already_pending).
+ if (isExclusionViolation(ex)) {
+ throw OrdinisException.conflict(
+ "draft_already_pending",
+ "На запись business_key=" + req.businessKey() + " уже есть pending draft.");
+ }
+ throw ex;
}
}
+ /** Distinguish exclusion-constraint violation (23P01) from other DataIntegrity. */
+ private static boolean isExclusionViolation(DataIntegrityViolationException ex) {
+ Throwable t = ex;
+ while (t != null) {
+ if (t instanceof java.sql.SQLException sql && "23P01".equals(sql.getSQLState())) {
+ return true;
+ }
+ t = t.getCause();
+ }
+ return false;
+ }
+
@Transactional(readOnly = true)
public RecordDraft findById(UUID id) {
return draftRepo.findById(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 f99bfd6..9131135 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
@@ -311,8 +311,14 @@ class DraftServiceTest {
&& d.getBusinessKey().equals(draft.getBusinessKey())
&& !d.getId().equals(draft.getId()));
if (conflict) {
+ // Wrap a SQLException with SQLState 23P01 — DraftService.submit
+ // distinguishes exclusion violations from other DataIntegrity errors
+ // by walking the cause chain (Phase 3 fix: don't blanket-mask check
+ // constraint failures as draft_already_pending).
+ java.sql.SQLException sql = new java.sql.SQLException(
+ "exclusion violation", "23P01");
throw new DataIntegrityViolationException(
- "record_drafts_one_pending_per_key");
+ "record_drafts_one_pending_per_key", sql);
}
store.put(draft.getId(), draft);
return draft;