feat(approval): Approval Workflow v2 Phase 3 — e2e suite + 0020 case fix

Phase 3 e2e coverage (8 tests, ApprovalWorkflowE2ETest):
- happyFlow: maker submit → checker approve → live record + outbox event
- rejectFlow: keeps draft REJECTED, no live record, re-approve blocked
- selfApprove: same JWT subject blocked (self_approve_forbidden / self_reject_forbidden)
- twoPendingDrafts: exclusion constraint → 409 draft_already_pending
- directCRUD on approval_required dict: POST/PUT/DELETE → 409 draft_required (D2=A)
- regression: dict без approval_required — direct CRUD по-прежнему работает
- withdrawByMaker: maker может, reviewer не может (403 not_draft_maker)
- rejectWithoutComment: 400 reject_reason_required

E2e exposed real Phase 0 bug:
Hibernate @Enumerated(EnumType.STRING) пишет UPPERCASE ('PENDING'), а
Liquibase 0019 check constraint + exclusion WHERE использовали lowercase
('pending'). Каждый insert проваливался на check, маппился в
DataIntegrityViolation, и DraftService.submit маскировал его в
draft_already_pending — скрывая реальную причину.

Fixes:
1. Migration 0020: пересоздание record_drafts_status_check + exclusion
   с UPPERCASE значениями (matches enum serialization).
2. DraftService.submit: только SQLSTATE 23P01 (exclusion violation)
   маппится в draft_already_pending. Остальные DataIntegrity (check, FK,
   NOT NULL) пробрасываются как-есть — иначе schema bugs продолжат
   маскироваться.
3. DraftServiceTest StubDraftRepo: оборачивает SQLException 23P01 в
   DataIntegrityViolation cause chain — отражает прод поведение.

Test counts:
- ordinis-rest-api unit: 119/119 PASS
- ordinis-app e2e: 28/28 PASS (был 20, +8 новых ApprovalWorkflowE2ETest)
This commit is contained in:
Zimin A.N.
2026-05-08 13:52:17 +03:00
parent 84a4de4ceb
commit 9ef9b8147e
5 changed files with 462 additions and 5 deletions
@@ -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.
*
* <p>Полный maker-checker lifecycle на реальном Postgres + Spring Security
* (JWT через {@link JwtTestSupport}). Maker и reviewer = разные subjects → проверяем
* self-approve guard, exclusion constraint, regression на dict без approval_required,
* outbox event после approve.
*
* <p>Coverage matrix (соответствует Phase 3 чеклисту в design doc):
* <ol>
* <li>{@link #happyFlow_submitApprove_recordLiveAndOutboxEvent} — full maker→checker</li>
* <li>{@link #rejectFlow_keepsDraft_noLiveRecord} — reject path</li>
* <li>{@link #selfApproveBlocked_409} — no_self_approve constraint</li>
* <li>{@link #twoPendingDraftsForSameBusinessKey_blocked} — one_pending_per_key</li>
* <li>{@link #directCrudBlockedWhenApprovalRequired} — D2=A gate (409 draft_required)</li>
* <li>{@link #regression_approvalNotRequired_directCrudStillWorks} — non-regression</li>
* <li>{@link #withdrawByMaker_keepsDraftStatus} — maker withdraw</li>
* <li>{@link #rejectWithoutComment_400} — reject_reason_required</li>
* </ol>
*/
@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"));
}
}
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<!--
Approval Workflow v2 — Phase 3 fix.
BUG (exposed by e2e): Hibernate с @Enumerated(EnumType.STRING) пишет значения
enum'а в UPPER CASE (PENDING, APPROVED, REJECTED, WITHDRAWN). А Liquibase 0019
объявил check constraint + exclusion WHERE с lowercase ('pending'). Любой
insert PENDING draft'а проваливал check constraint, маппился в
DataIntegrityViolationException, и DraftService.submit ловил это как
draft_already_pending — маскируя реальную причину.
Fix: переписать ограничения с UPPERCASE значениями (matches Java enum norm).
Существующих PENDING-row быть не должно (insert никогда не проходил), но на
всякий случай конвертируем lowercase → UPPERCASE до пересоздания constraint'ов.
-->
<changeSet id="0020-1-normalize-existing-status" author="ordinis">
<comment>Normalize any prior lowercase status values to UPPERCASE before recreate.</comment>
<sql>UPDATE record_drafts SET status = UPPER(status) WHERE status != UPPER(status);</sql>
</changeSet>
<changeSet id="0020-2-fix-status-check" author="ordinis">
<comment>Recreate status check constraint with UPPERCASE values matching enum.</comment>
<sql>ALTER TABLE record_drafts DROP CONSTRAINT IF EXISTS record_drafts_status_check;</sql>
<sql>
ALTER TABLE record_drafts
ADD CONSTRAINT record_drafts_status_check
CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED', 'WITHDRAWN'));
</sql>
</changeSet>
<changeSet id="0020-3-fix-exclusion-where" author="ordinis">
<comment>Recreate exclusion WHERE clause to use 'PENDING' (matches enum serialization).</comment>
<sql>ALTER TABLE record_drafts DROP CONSTRAINT IF EXISTS record_drafts_one_pending_per_key;</sql>
<sql>
ALTER TABLE record_drafts
ADD CONSTRAINT record_drafts_one_pending_per_key
EXCLUDE USING btree (
dictionary_id WITH =,
business_key WITH =
) WHERE (status = 'PENDING');
</sql>
</changeSet>
</databaseChangeLog>
@@ -29,5 +29,6 @@
<include file="changes/0017-redis-projection-flag.xml" relativeToChangelogFile="true"/>
<include file="changes/0018-trgm-search-index.xml" relativeToChangelogFile="true"/>
<include file="changes/0019-approval-workflow-v2.xml" relativeToChangelogFile="true"/>
<include file="changes/0020-fix-draft-status-case.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -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)
@@ -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;