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
@@ -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;