[CLAUDE] PurchaseEvaluation: require quy trinh duyet V2 o create+submit (dong lo hong quy-trinh-cu)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m58s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m58s
Validator NotEmpty(ApprovalWorkflowId) + submit guard (sau Section-3, truoc mutate phase). Dong lo hong validate FE-only -> phieu null-workflow ket nhanh V1-legacy "quy trinh cu" (khong Buoc/Cap, khong route duyet). Test-before (RED confirmed): +2 validator test (PeWorkItemGuardTests) + rewrite test 7/13 PeSubmitGuardAndBypass (V1-submit deprecated, data V1 wipe S59). 356 pass (45D+311I). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -41,6 +41,12 @@ public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<Create
|
||||
// async-db; mirror S43 CreateLeaveRequestHandler FK-invariant guard).
|
||||
RuleFor(x => x.WorkItemId).NotEmpty()
|
||||
.WithMessage("Phải chọn hạng mục công việc.");
|
||||
// [S83 bug fix] BE enforce quy trình duyệt V2 — đóng lỗ hổng validate
|
||||
// FE-only (FE canSubmit bắt buộc nhưng BE chỉ validate-nếu-có-truyền →
|
||||
// phiếu null-workflow lọt vào ChoDuyet = kẹt V1-legacy "quy trình cũ").
|
||||
// NotEmpty chặn cả null lẫn Guid.Empty.
|
||||
RuleFor(x => x.ApprovalWorkflowId).NotEmpty()
|
||||
.WithMessage("Phải chọn quy trình duyệt.");
|
||||
RuleFor(x => x.DiaDiem).MaximumLength(500);
|
||||
RuleFor(x => x.MoTa).MaximumLength(2000);
|
||||
// [HoSoLink] MaxLength MATCH EF config HasMaxLength(1000) (S35 lesson).
|
||||
|
||||
@ -215,6 +215,18 @@ public class PurchaseEvaluationWorkflowService(
|
||||
+ string.Join(" · ", missing) + ".");
|
||||
}
|
||||
|
||||
// [S83 bug fix] Guard require-workflow: KHÔNG cho gửi duyệt phiếu chưa
|
||||
// pin quy trình duyệt V2 (ApprovalWorkflowId null → kẹt nhánh V1-legacy:
|
||||
// không Bước/Cấp, banner "quy trình cũ", không route duyệt được). FE bắt
|
||||
// buộc lúc create (canSubmit) nhưng BE phải enforce defense-in-depth
|
||||
// (validate FE-only = lỗ hổng, họ gotcha #44). Đặt SAU Section-3
|
||||
// (orthogonal — ưu tiên báo thiếu data) NHƯNG TRƯỚC mutate phase. Data
|
||||
// V1 cũ đã wipe S59 → chặn V1-submit an toàn (mọi phiếu mới = V2).
|
||||
if (evaluation.ApprovalWorkflowId is null)
|
||||
throw new ConflictException(
|
||||
"Phiếu chưa chọn quy trình duyệt — không thể gửi duyệt. "
|
||||
+ "Vui lòng mở Sửa, chọn \"Quy trình duyệt\" rồi gửi lại.");
|
||||
|
||||
evaluation.Phase = PurchaseEvaluationPhase.ChoDuyet;
|
||||
|
||||
// Mig 31 (S23 t1 Plan K) — F2 Drafter-skip-from-Nháp semantic deprecated.
|
||||
|
||||
@ -74,7 +74,7 @@ public class PeWorkItemGuardTests
|
||||
}
|
||||
|
||||
private static CreatePurchaseEvaluationCommand BuildCreateCommand(
|
||||
Guid projectId, Guid? workItemId)
|
||||
Guid projectId, Guid? workItemId, Guid? approvalWorkflowId = null)
|
||||
=> new(
|
||||
Type: PurchaseEvaluationType.DuyetNcc,
|
||||
TenGoiThau: "Gói thầu test",
|
||||
@ -84,7 +84,7 @@ public class PeWorkItemGuardTests
|
||||
MoTa: null,
|
||||
PaymentTerms: null,
|
||||
BudgetPeriodAmount: null,
|
||||
ApprovalWorkflowId: null,
|
||||
ApprovalWorkflowId: approvalWorkflowId,
|
||||
WorkItemId: workItemId);
|
||||
|
||||
// ============================================================
|
||||
@ -127,10 +127,10 @@ public class PeWorkItemGuardTests
|
||||
[Fact]
|
||||
public void Validator_WorkItemIdPresent_NoErrorOnWorkItemId()
|
||||
{
|
||||
// Chỉ assert rule WorkItemId pass — command còn lại đã hợp lệ ở BuildCreateCommand
|
||||
// nên result.IsValid=true; nhưng narrow assert vào property để test đúng rule này.
|
||||
// Chỉ assert rule WorkItemId pass — command còn lại đã hợp lệ ở BuildCreateCommand.
|
||||
// [S83] +ApprovalWorkflowId (cũng NotEmpty từ S83) để command FULL-valid → IsValid=true.
|
||||
var validator = new CreatePurchaseEvaluationCommandValidator();
|
||||
var cmd = BuildCreateCommand(Guid.NewGuid(), workItemId: Guid.NewGuid());
|
||||
var cmd = BuildCreateCommand(Guid.NewGuid(), workItemId: Guid.NewGuid(), approvalWorkflowId: Guid.NewGuid());
|
||||
|
||||
var result = validator.Validate(cmd);
|
||||
|
||||
@ -138,6 +138,38 @@ public class PeWorkItemGuardTests
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 1b. VALIDATOR — RuleFor(ApprovalWorkflowId).NotEmpty() [S83 bug fix]
|
||||
// ============================================================
|
||||
// Đóng lỗ hổng validate FE-only: phiếu null-workflow lọt vào ChoDuyet = kẹt nhánh
|
||||
// V1-legacy ("quy trình cũ", không Bước/Cấp, không route duyệt). FE canSubmit bắt
|
||||
// buộc chọn quy trình lúc create → BE validator phải mirror (defense-in-depth).
|
||||
|
||||
[Fact]
|
||||
public void Validator_ApprovalWorkflowIdNull_IsInvalid_WithErrorOnWorkflow()
|
||||
{
|
||||
var validator = new CreatePurchaseEvaluationCommandValidator();
|
||||
var cmd = BuildCreateCommand(Guid.NewGuid(), workItemId: Guid.NewGuid(), approvalWorkflowId: null);
|
||||
|
||||
var result = validator.Validate(cmd);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(
|
||||
e => e.PropertyName == nameof(CreatePurchaseEvaluationCommand.ApprovalWorkflowId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_ApprovalWorkflowIdPresent_NoErrorOnWorkflow()
|
||||
{
|
||||
var validator = new CreatePurchaseEvaluationCommandValidator();
|
||||
var cmd = BuildCreateCommand(Guid.NewGuid(), workItemId: Guid.NewGuid(), approvalWorkflowId: Guid.NewGuid());
|
||||
|
||||
var result = validator.Validate(cmd);
|
||||
|
||||
result.Errors.Should().NotContain(
|
||||
e => e.PropertyName == nameof(CreatePurchaseEvaluationCommand.ApprovalWorkflowId));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. CREATE HANDLER — FK-invariant guard
|
||||
// ============================================================
|
||||
|
||||
@ -34,6 +34,12 @@ namespace SolutionErp.Infrastructure.Tests.Services;
|
||||
//
|
||||
// LƯU Ý GUARD-FIRST: submit guard chạy TRƯỚC bypass → mọi test bypass phải dựng
|
||||
// PE ĐỦ 4 điều kiện Section 3 (winner + quote>0 + manual budget + comparison file).
|
||||
//
|
||||
// [S83 spec change] Thêm guard require-workflow (SAU Section-3, TRƯỚC mutate phase):
|
||||
// phiếu KHÔNG pin ApprovalWorkflowId KHÔNG được gửi duyệt (đóng lỗ hổng "quy trình
|
||||
// cũ" — phiếu null-workflow kẹt nhánh V1-legacy). Test (7) + (13) cập nhật từ
|
||||
// "submit OK no-workflow" → "throws". V1-submit deprecated (data V1 cũ wipe S59,
|
||||
// mọi phiếu mới = V2). Bypass tests (9-12,14) pin workflow → KHÔNG ảnh hưởng.
|
||||
public class PeSubmitGuardAndBypassTests
|
||||
{
|
||||
private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix,
|
||||
@ -337,12 +343,13 @@ public class PeSubmitGuardAndBypassTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_AllFourMet_ManualBudget_NoWorkflow_SetsChoDuyet()
|
||||
public async Task Submit_AllFourMet_NoWorkflow_ThrowsWorkflowRequired_S83()
|
||||
{
|
||||
// (7) Đủ 4 (manual budget > 0, KHÔNG BudgetId, KHÔNG ApprovalWorkflowId) →
|
||||
// submit OK Phase=ChoDuyet. V1/no-workflow: pointer StepIdx=0, Level null
|
||||
// (line 208 — chỉ init level=1 nếu V2).
|
||||
var (svc, fix, db, clock) = CreateService();
|
||||
// (7) [S83 spec change] Đủ 4 Section 3 NHƯNG KHÔNG pin ApprovalWorkflowId →
|
||||
// submit BỊ CHẶN. Trước S83: set ChoDuyet với level pointer null (V1-legacy);
|
||||
// từ S83: require-workflow guard (SAU Section-3, TRƯỚC mutate) → Conflict,
|
||||
// phiếu giữ Nháp. Đóng lỗ hổng "quy trình cũ" (phiếu null-workflow kẹt V1).
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var pe = BuildPeNhap(budgetPeriodAmount: 750_000m);
|
||||
@ -353,12 +360,12 @@ public class PeSubmitGuardAndBypassTests
|
||||
SeedComparisonAttachment(db, pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await SubmitAsync(svc, pe, Guid.NewGuid());
|
||||
var act = () => SubmitAsync(svc, pe, Guid.NewGuid());
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(0);
|
||||
pe.CurrentApprovalLevelOrder.Should().BeNull("phiếu không pin V2 → level pointer null");
|
||||
pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7));
|
||||
var ex = await act.Should().ThrowAsync<ConflictException>();
|
||||
ex.Which.Message.Should().Contain("quy trình duyệt");
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao,
|
||||
"guard require-workflow chặn TRƯỚC mutate phase");
|
||||
}
|
||||
}
|
||||
|
||||
@ -541,10 +548,12 @@ public class PeSubmitGuardAndBypassTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_V1Phieu_NoApprovalWorkflowId_SubmitsOk_NoBypass_NoCrash()
|
||||
public async Task Submit_V1Phieu_NoApprovalWorkflowId_NowBlocked_S83()
|
||||
{
|
||||
// (13) Phiếu V1 (ApprovalWorkflowId null) → submit OK, KHÔNG bypass (V2-only),
|
||||
// KHÔNG crash. Đủ 4 điều kiện Section 3 vẫn áp.
|
||||
// (13) [S83 spec change] Trước S83: phiếu V1 (ApprovalWorkflowId null) submit OK
|
||||
// (V1-legacy). Từ S83: require-workflow guard CHẶN — V1-submit deprecated (data
|
||||
// V1 cũ đã wipe S59, mọi phiếu mới = V2). Đủ 4 Section 3 vẫn bị chặn ở bước
|
||||
// workflow. Để gửi duyệt: mở Sửa chọn quy trình V2.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
@ -557,15 +566,12 @@ public class PeSubmitGuardAndBypassTests
|
||||
SeedComparisonAttachment(db, pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await SubmitAsync(svc, pe, drafter);
|
||||
var act = () => SubmitAsync(svc, pe, drafter);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
|
||||
pe.CurrentApprovalLevelOrder.Should().BeNull("V1 → level pointer null, không bypass");
|
||||
|
||||
var autoApprovals = await db.PurchaseEvaluationApprovals
|
||||
.Where(a => a.PurchaseEvaluationId == pe.Id
|
||||
&& a.Decision == ApprovalDecision.AutoApprove).ToListAsync();
|
||||
autoApprovals.Should().BeEmpty("V1 không bypass → 0 AutoApprove row");
|
||||
var ex = await act.Should().ThrowAsync<ConflictException>();
|
||||
ex.Which.Message.Should().Contain("quy trình duyệt");
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao,
|
||||
"V1-submit deprecated S83 → giữ Nháp");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user