diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PeUpdateDraftWorkflowPreserveTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PeUpdateDraftWorkflowPreserveTests.cs new file mode 100644 index 0000000..683dd8d --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/PeUpdateDraftWorkflowPreserveTests.cs @@ -0,0 +1,181 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Application.PurchaseEvaluations; +using SolutionErp.Domain.ApprovalWorkflowsV2; +using SolutionErp.Domain.PurchaseEvaluations; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Application; + +// S85 TEST-AFTER cho ROOT FIX bug-class S42 (anh Kiệt FDC UAT R5). +// BUG (đã fix em-main): form Sửa header CLEAR Quy trình duyệt của phiếu. +// Trước: entity.ApprovalWorkflowId = request.ApprovalWorkflowId (ABSOLUTE-SET → +// FE form Sửa KHÔNG gửi field → request.ApprovalWorkflowId = null → CLEAR WF +// → kết submit "chưa chọn quy trình" = whack-a-mole bug-class S42). +// Sau (fix): if (request.ApprovalWorkflowId is not null) +// entity.ApprovalWorkflowId = request.ApprovalWorkflowId; +// → client không gửi (null) GIỮ WF cũ; CHỈ đổi khi FE gửi giá trị thật. +// +// Test theo CODE (single source of truth, S34) — PurchaseEvaluationFeatures.cs: +// - Line 285-286: null-safe guard (giống BudgetPeriodAmount/ExpectedRemaining/WorkItemId). +// - Line 253-266: KHI awId != entity.ApprovalWorkflowId (tức CÓ đổi) → validate WF +// tồn tại + ApplicableType khớp Type phiếu. PRESERVE case (request null) SHORT-CIRCUIT +// trước validate đó (vì `is not null` false) → KHÔNG cần seed WF cho case PRESERVE. +// +// Handler: UpdatePurchaseEvaluationDraftCommandHandler(IApplicationDbContext db, ICurrentUser). +// Phase guard: chỉ DangSoanThao / TraLai. Changelog Add → cần IdentityFixture (SaveChanges). +public class PeUpdateDraftWorkflowPreserveTests +{ + private sealed class FakeCurrentUser : ICurrentUser + { + public Guid? UserId { get; init; } = Guid.NewGuid(); + public string? Email { get; init; } + public string? FullName { get; init; } = "Người soạn"; + public IReadOnlyList Roles { get; init; } = System.Array.Empty(); + public bool IsAuthenticated => UserId is not null; + } + + // Seed 1 PE Nháp với ApprovalWorkflowId gán sẵn (mô phỏng phiếu ĐÃ chọn WF). + private static PurchaseEvaluation BuildPe( + Guid? approvalWorkflowId, + decimal? budgetPeriodAmount = null, + string code = "PE-WFP-001") + => new() + { + Id = Guid.NewGuid(), + Type = PurchaseEvaluationType.DuyetNcc, + Phase = PurchaseEvaluationPhase.DangSoanThao, + MaPhieu = code, + TenGoiThau = "Gói thầu test", + ProjectId = Guid.NewGuid(), + DrafterUserId = Guid.NewGuid(), + ApprovalWorkflowId = approvalWorkflowId, + BudgetPeriodAmount = budgetPeriodAmount, + }; + + // Seed 1 ApprovalWorkflow V2 thật, ApplicableType=DuyetNcc (khớp PE Type) để pass + // validate block khi đổi WF (CHANGE case). Trả về Id. + private static async Task SeedWorkflowAsync(TestApplicationDbContext db, string code) + { + var wf = new ApprovalWorkflow + { + Id = Guid.NewGuid(), + Code = code, + Version = 1, + ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc, + Name = code, + IsActive = true, + IsUserSelectable = true, + }; + db.ApprovalWorkflows.Add(wf); + await db.SaveChangesAsync(CancellationToken.None); + return wf.Id; + } + + // ============================================================ + // CASE 1 — PRESERVE (cốt lõi bug-class S42, reproduce + verify fix) + // ============================================================ + + [Fact] + public async Task UpdateDraft_NullApprovalWorkflowId_PreservesExistingWorkflow() + { + // Phiếu Nháp CÓ ApprovalWorkflowId=W → UpdateDraft với ApprovalWorkflowId=null + // (FE form Sửa header KHÔNG gửi field) → entity.ApprovalWorkflowId PHẢI VẪN == W. + // TRƯỚC FIX: absolute-set null → W bị clear (= bug). SAU FIX: null-safe → giữ W. + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + // PE.ApprovalWorkflowId là FK Restrict → seed WF thật (case này request null nên + // KHÔNG đi vào validate block, nhưng PE seed vẫn cần FK hợp lệ). + var w = await SeedWorkflowAsync(db, "QT-DN-V2-PRESERVE"); + var pe = BuildPe(approvalWorkflowId: w, code: "PE-WFP-PRESERVE"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + // Update header KHÔNG kèm ApprovalWorkflowId (default null) — các field khác có giá trị. + var cmd = new UpdatePurchaseEvaluationDraftCommand( + pe.Id, + TenGoiThau: "Gói thầu đã sửa", + DiaDiem: "Công trường A", + MoTa: "Mô tả mới", + PaymentTerms: "30 ngày"); // ApprovalWorkflowId / WorkItemId / Budget* để default null + var handler = new UpdatePurchaseEvaluationDraftCommandHandler(db, new FakeCurrentUser()); + await handler.Handle(cmd, CancellationToken.None); + + var reload = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reload.ApprovalWorkflowId.Should().Be(w, + "form Sửa KHÔNG gửi approvalWorkflowId (null) → null-safe guard GIỮ WF cũ (root fix bug-class S42)"); + // Sanity: field text vẫn cập nhật bình thường (handler vẫn chạy, không phải no-op). + reload.TenGoiThau.Should().Be("Gói thầu đã sửa"); + reload.DiaDiem.Should().Be("Công trường A"); + } + + // ============================================================ + // CASE 2 — CHANGE (đổi WF khi CÓ gửi giá trị thật) + // ============================================================ + + [Fact] + public async Task UpdateDraft_NonNullApprovalWorkflowId_ChangesToNewWorkflow() + { + // Phiếu có WF=W1 → UpdateDraft với ApprovalWorkflowId=W2 (W2 != null) → + // entity.ApprovalWorkflowId == W2. CHO PHÉP đổi khi FE gửi giá trị. + // W2 phải là WF thật ApplicableType=DuyetNcc để pass validate block (line 253-266). + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + var w1 = await SeedWorkflowAsync(db, "QT-DN-V2-W1"); + var w2 = await SeedWorkflowAsync(db, "QT-DN-V2-W2"); + + var pe = BuildPe(approvalWorkflowId: w1, code: "PE-WFP-CHANGE"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + var cmd = new UpdatePurchaseEvaluationDraftCommand( + pe.Id, + TenGoiThau: "Gói thầu test", + DiaDiem: null, + MoTa: null, + PaymentTerms: null, + ApprovalWorkflowId: w2); // FE GỬI W2 → đổi + var handler = new UpdatePurchaseEvaluationDraftCommandHandler(db, new FakeCurrentUser()); + await handler.Handle(cmd, CancellationToken.None); + + var reload = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reload.ApprovalWorkflowId.Should().Be(w2, + "FE gửi ApprovalWorkflowId không-null → cho phép đổi WF (null-safe guard chỉ chặn null, không chặn đổi)"); + } + + // ============================================================ + // CASE 3 (bonus) — mirror null-safe field BudgetPeriodAmount đã có (S61) + // ============================================================ + + [Fact] + public async Task UpdateDraft_NullBudgetPeriodAmount_PreservesExistingBudget() + { + // Cùng convention null-safe S42 (line 289-290): phiếu có BudgetPeriodAmount=100tr, + // UpdateDraft không gửi (null) → GIỮ 100tr (KHÔNG null-hóa). Đối chứng cho thấy + // ApprovalWorkflowId nay đã CÙNG pattern với Budget* (đồng nhất bug-class S42). + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + const decimal budget = 100_000_000m; + var w = await SeedWorkflowAsync(db, "QT-DN-V2-BUDGET"); // FK Restrict → WF thật + var pe = BuildPe(approvalWorkflowId: w, budgetPeriodAmount: budget, code: "PE-WFP-BUDGET"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + var cmd = new UpdatePurchaseEvaluationDraftCommand( + pe.Id, + TenGoiThau: "Gói thầu test", + DiaDiem: null, + MoTa: null, + PaymentTerms: null); // BudgetPeriodAmount default null → giữ + var handler = new UpdatePurchaseEvaluationDraftCommandHandler(db, new FakeCurrentUser()); + await handler.Handle(cmd, CancellationToken.None); + + var reload = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reload.BudgetPeriodAmount.Should().Be(budget, + "BudgetPeriodAmount null-safe (S61) — null request GIỮ giá trị cũ; cùng pattern ApprovalWorkflowId (S85)"); + } +}