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:
+382
@@ -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"));
|
||||
}
|
||||
}
|
||||
+50
@@ -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>
|
||||
|
||||
+22
-4
@@ -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)
|
||||
|
||||
+7
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user