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:
+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