From 3d76c6bc0c434073b2631c7b1e32f29375e3e572 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Thu, 7 May 2026 18:23:59 +0700 Subject: [PATCH] [CLAUDE] Tests: PE N-stage workflow approval (6 test) + IdentityFixture extend (Chunk D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PeNStageApprovalTests.cs (NEW, 6 test) cover N-stage logic Mig 18: 1. NStage_FirstInner_NV_Approve_Blocks_Phase_Transition NV.PRO duyệt cấp 1 → 1 row InnerStepId set, phase chưa đổi (còn 2 cấp). 2. NStage_All_3_Levels_Sequential_Pass_Allow_Phase_Transition NV → PP → TP duyệt lần lượt → 3 rows + phase chuyển. Order asc enforce. 3. NStage_TP_Bypass_Skips_Lower_Levels_Same_Dept TP có CanBypassReview → 1 transition tạo 3 rows (NV+PP IsBypassed=true, TP exact match). Audit chuẩn. 4. NStage_Wrong_Department_Throws_Forbidden Actor dept khác inner step's dept → ForbiddenException. 5. NStage_Reject_Clears_InnerStep_Rows_At_Phase NV approve → 1 row. Reject → DangSoanThao + RejectedFromPhase set + N-stage rows cleared (resume sẽ approve lại). 6. LegacyFallback_NoInnerSteps_Uses_2Stage_Logic PE không pin WorkflowDefinitionId → service fallback hardcoded policy → no inner steps → legacy 2-stage Stage=Review/Confirm logic kick in. IdentityFixture.CreateUserAsync extend +PositionLevel? param (default null cho admin/system user). Helper SeedWorkflowDefinitionAsync: tạo definition với 2 steps adjacent (ChoPurchasing có inner steps + ChoCCM next) — đủ cho FromDefinition build transition policy guard pass actor role Procurement. Verify: 83 → **89 test pass** (54 Domain + 35 Infra: 17 codegen + 6 PE WF versioning + 6 PE 2-stage + 6 PE N-stage). 0 fail. Pending Chunk E: API endpoints (PATCH /users/{id}/position-level + DTO extend in PeWorkflowsController bind tự động). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Common/IdentityFixture.cs | 6 +- .../Services/PeNStageApprovalTests.cs | 324 ++++++++++++++++++ 2 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 tests/SolutionErp.Infrastructure.Tests/Services/PeNStageApprovalTests.cs diff --git a/tests/SolutionErp.Infrastructure.Tests/Common/IdentityFixture.cs b/tests/SolutionErp.Infrastructure.Tests/Common/IdentityFixture.cs index 8c3e69e..d571c90 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Common/IdentityFixture.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Common/IdentityFixture.cs @@ -68,13 +68,14 @@ public sealed class IdentityFixture : IDisposable ctx.Database.EnsureCreated(); } - // Helper: tạo user + assign roles + gán DepartmentId. Reuse trong nhiều test. + // Helper: tạo user + assign roles + gán DepartmentId + PositionLevel. Reuse trong nhiều test. public async Task CreateUserAsync( string email, string fullName, Guid? departmentId, string[] roles, - bool canBypassReview = false) + bool canBypassReview = false, + PositionLevel? positionLevel = null) { var um = Services.GetRequiredService>(); var rm = Services.GetRequiredService>(); @@ -95,6 +96,7 @@ public sealed class IdentityFixture : IDisposable FullName = fullName, DepartmentId = departmentId, CanBypassReview = canBypassReview, + PositionLevel = positionLevel, IsActive = true, }; var created = await um.CreateAsync(user, "Test@123"); diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PeNStageApprovalTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PeNStageApprovalTests.cs new file mode 100644 index 0000000..e946b2e --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PeNStageApprovalTests.cs @@ -0,0 +1,324 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Domain.Common; +using SolutionErp.Domain.Contracts; +using SolutionErp.Domain.Identity; +using SolutionErp.Domain.PurchaseEvaluations; +using SolutionErp.Infrastructure.Services; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Services; + +// Tests cho N-stage department approval logic (Mig 18) ở +// PurchaseEvaluationWorkflowService. Cover chuỗi inner step Order asc theo +// Department × PositionLevel. Bypass cùng dept (TP có CanBypassReview). +// +// Pattern: dùng IdentityFixture + seed WorkflowDefinition pinned to PE. +// Reuse FakeNotificationService + FixedDateTime từ PeTwoStageApprovalTests.cs. +public class PeNStageApprovalTests : IClassFixture +{ + private readonly IdentityFixture _fx; + private readonly TestApplicationDbContext _db; + private readonly UserManager _userManager; + private readonly PurchaseEvaluationWorkflowService _service; + private readonly Guid _deptPro; + private readonly Guid _deptCcm; + + public PeNStageApprovalTests(IdentityFixture fx) + { + _fx = fx; + _db = fx.Services.GetRequiredService(); + _userManager = fx.Services.GetRequiredService>(); + + _deptPro = SeedDept("PRO-NS", "Phòng Cung ứng (NS)"); + _deptCcm = SeedDept("CCM-NS", "Phòng Kiểm soát (NS)"); + + var clock = new FixedDateTime(new DateTime(2026, 5, 7, 10, 0, 0, DateTimeKind.Utc)); + var fakeNotifications = new FakeNotificationService(); + + _service = new PurchaseEvaluationWorkflowService( + _db, clock, fakeNotifications, _userManager); + } + + private Guid SeedDept(string code, string name) + { + var existing = _db.Departments.FirstOrDefault(d => d.Code == code); + if (existing is not null) return existing.Id; + var d = new SolutionErp.Domain.Master.Department { Id = Guid.NewGuid(), Code = code, Name = name }; + _db.Departments.Add(d); + _db.SaveChanges(); + return d.Id; + } + + private async Task SeedWorkflowDefinitionAsync( + params (Guid deptId, PositionLevel level)[] innerSteps) + { + // 2 step adjacent: ChoPurchasing (current, có inner steps) → ChoCCM (next). + // FromDefinition build transition (ChoPurchasing → ChoCCM) từ step[1].Approvers role. + var def = new PurchaseEvaluationWorkflowDefinition + { + Id = Guid.NewGuid(), + Code = $"NS-TEST-{Guid.NewGuid():N}".Substring(0, 20), + Version = 1, + EvaluationType = PurchaseEvaluationType.DuyetNcc, + Name = "N-stage test workflow", + IsActive = true, + ActivatedAt = DateTime.UtcNow, + }; + var step1 = new PurchaseEvaluationWorkflowStep + { + Id = Guid.NewGuid(), + Order = 1, + Phase = PurchaseEvaluationPhase.ChoPurchasing, + Name = "Duyệt Purchasing", + Approvers = + { + new PurchaseEvaluationWorkflowStepApprover + { + Kind = WorkflowApproverKind.Role, + AssignmentValue = "Procurement", + }, + }, + }; + for (int i = 0; i < innerSteps.Length; i++) + { + step1.InnerSteps.Add(new PurchaseEvaluationWorkflowStepInnerStep + { + Id = Guid.NewGuid(), + Order = i + 1, + DepartmentId = innerSteps[i].deptId, + PositionLevel = innerSteps[i].level, + IsRequired = true, + }); + } + def.Steps.Add(step1); + // Step 2 — chỉ để FromDefinition build transition (ChoPurchasing → ChoCCM). + // KHÔNG có inner steps → nếu PE chuyển tiếp tới phase này, sẽ fallback legacy + // hoặc admin bypass (test scope chỉ chuyển tới đây 1 lần). + def.Steps.Add(new PurchaseEvaluationWorkflowStep + { + Id = Guid.NewGuid(), + Order = 2, + Phase = PurchaseEvaluationPhase.ChoCCM, + Name = "Duyệt CCM", + Approvers = + { + new PurchaseEvaluationWorkflowStepApprover + { + Kind = WorkflowApproverKind.Role, + AssignmentValue = "Procurement", // mirror step 1 để policy guard accept actor cùng role + }, + }, + }); + _db.PurchaseEvaluationWorkflowDefinitions.Add(def); + await _db.SaveChangesAsync(); + return def.Id; + } + + private async Task SeedPeAsync( + PurchaseEvaluationPhase phase, + Guid? workflowDefinitionId = null, + Guid? projectId = null) + { + var pid = projectId ?? Guid.NewGuid(); + if (!_db.Projects.Any(p => p.Id == pid)) + { + _db.Projects.Add(new SolutionErp.Domain.Master.Project + { + Id = pid, + Code = $"PRJ-NS-{Random.Shared.Next(10000):D4}", + Name = "Test project NS", + }); + } + + var pe = new PurchaseEvaluation + { + Id = Guid.NewGuid(), + Type = PurchaseEvaluationType.DuyetNcc, + Phase = phase, + TenGoiThau = "Test gói thầu NS", + ProjectId = pid, + WorkflowDefinitionId = workflowDefinitionId, + }; + _db.PurchaseEvaluations.Add(pe); + await _db.SaveChangesAsync(); + return pe; + } + + [Fact] + public async Task NStage_FirstInner_NV_Approve_Blocks_Phase_Transition() + { + // Arrange: 1 dept (PRO) × 3 cấp. + var defId = await SeedWorkflowDefinitionAsync( + (_deptPro, PositionLevel.NhanVien), + (_deptPro, PositionLevel.PhoPhong), + (_deptPro, PositionLevel.TruongPhong)); + var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing, defId); + + var nv = await _fx.CreateUserAsync( + $"nv-pro-ns-{Guid.NewGuid():N}@test", "NV PRO NS", + _deptPro, ["Procurement"], positionLevel: PositionLevel.NhanVien); + + // Act: NV.PRO duyệt cấp 1 (NV). + await _service.TransitionAsync( + pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"], + ApprovalDecision.Approve, "duyệt NV"); + + // Assert: phase chưa đổi (còn 2 cấp PP+TP), 1 row InnerStepId set. + var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoPurchasing); + + var rows = await _db.PurchaseEvaluationDepartmentApprovals.AsNoTracking() + .Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync(); + rows.Should().HaveCount(1); + rows[0].InnerStepId.Should().NotBeNull(); + rows[0].IsBypassed.Should().BeFalse(); + rows[0].ApproverRoleSnapshot.Should().Contain("NhanVien"); + } + + [Fact] + public async Task NStage_All_3_Levels_Sequential_Pass_Allow_Phase_Transition() + { + // Arrange: 1 dept × 3 cấp. + var defId = await SeedWorkflowDefinitionAsync( + (_deptPro, PositionLevel.NhanVien), + (_deptPro, PositionLevel.PhoPhong), + (_deptPro, PositionLevel.TruongPhong)); + var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing, defId); + + var nv = await _fx.CreateUserAsync($"nv-{Guid.NewGuid():N}@test", "NV", _deptPro, ["Procurement"], positionLevel: PositionLevel.NhanVien); + var pp = await _fx.CreateUserAsync($"pp-{Guid.NewGuid():N}@test", "PP", _deptPro, ["Procurement"], positionLevel: PositionLevel.PhoPhong); + var tp = await _fx.CreateUserAsync($"tp-{Guid.NewGuid():N}@test", "TP", _deptPro, ["Procurement"], positionLevel: PositionLevel.TruongPhong); + + // Act: lần lượt NV → PP → TP. + await _service.TransitionAsync(pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"], ApprovalDecision.Approve, "NV"); + pe = await _db.PurchaseEvaluations.FirstAsync(x => x.Id == pe.Id); + await _service.TransitionAsync(pe, PurchaseEvaluationPhase.ChoCCM, pp.Id, ["Procurement"], ApprovalDecision.Approve, "PP"); + pe = await _db.PurchaseEvaluations.FirstAsync(x => x.Id == pe.Id); + await _service.TransitionAsync(pe, PurchaseEvaluationPhase.ChoCCM, tp.Id, ["Procurement"], ApprovalDecision.Approve, "TP"); + + // Assert: phase chuyển + 3 rows + KHÔNG bypass. + var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM); + + var rows = await _db.PurchaseEvaluationDepartmentApprovals.AsNoTracking() + .Where(a => a.PurchaseEvaluationId == pe.Id && a.InnerStepId != null).ToListAsync(); + rows.Should().HaveCount(3); + rows.All(r => !r.IsBypassed).Should().BeTrue(); + } + + [Fact] + public async Task NStage_TP_Bypass_Skips_Lower_Levels_Same_Dept() + { + // Arrange: 1 dept × 3 cấp. TP có CanBypassReview=true. + var defId = await SeedWorkflowDefinitionAsync( + (_deptPro, PositionLevel.NhanVien), + (_deptPro, PositionLevel.PhoPhong), + (_deptPro, PositionLevel.TruongPhong)); + var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing, defId); + + var tp = await _fx.CreateUserAsync( + $"tp-bypass-{Guid.NewGuid():N}@test", "TP bypass", + _deptPro, ["Procurement"], + canBypassReview: true, positionLevel: PositionLevel.TruongPhong); + + // Act: TP bypass approve trực tiếp (skip NV+PP cùng dept). + await _service.TransitionAsync( + pe, PurchaseEvaluationPhase.ChoCCM, tp.Id, ["Procurement"], + ApprovalDecision.Approve, "TP bypass"); + + // Assert: phase chuyển, 3 rows (NV+PP=bypass true, TP=bypass false). + var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM); + + var rows = await _db.PurchaseEvaluationDepartmentApprovals.AsNoTracking() + .Where(a => a.PurchaseEvaluationId == pe.Id && a.InnerStepId != null) + .ToListAsync(); + rows.Should().HaveCount(3); + rows.Count(r => r.IsBypassed).Should().Be(2); // NV + PP bypassed + rows.Count(r => !r.IsBypassed).Should().Be(1); // TP exact match + } + + [Fact] + public async Task NStage_Wrong_Department_Throws_Forbidden() + { + // Arrange: inner step yêu cầu dept PRO. Actor thuộc CCM. + var defId = await SeedWorkflowDefinitionAsync( + (_deptPro, PositionLevel.NhanVien)); + var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing, defId); + + var ccmActor = await _fx.CreateUserAsync( + $"ccm-wrong-{Guid.NewGuid():N}@test", "CCM wrong", + _deptCcm, ["Procurement"], positionLevel: PositionLevel.NhanVien); + + // Act + Assert. + var act = async () => await _service.TransitionAsync( + pe, PurchaseEvaluationPhase.ChoCCM, ccmActor.Id, ["Procurement"], + ApprovalDecision.Approve, "wrong dept"); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task NStage_Reject_Clears_InnerStep_Rows_At_Phase() + { + // Arrange: NV approve trước → 1 row N-stage. Sau đó reject. + var defId = await SeedWorkflowDefinitionAsync( + (_deptPro, PositionLevel.NhanVien), + (_deptPro, PositionLevel.PhoPhong)); + var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing, defId); + + var nv = await _fx.CreateUserAsync($"nv-rej-{Guid.NewGuid():N}@test", "NV", _deptPro, ["Procurement"], positionLevel: PositionLevel.NhanVien); + await _service.TransitionAsync(pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"], ApprovalDecision.Approve, "NV"); + + var rowsBeforeReject = await _db.PurchaseEvaluationDepartmentApprovals.AsNoTracking() + .Where(a => a.PurchaseEvaluationId == pe.Id && a.InnerStepId != null).CountAsync(); + rowsBeforeReject.Should().Be(1); + + pe = await _db.PurchaseEvaluations.FirstAsync(x => x.Id == pe.Id); + + // Act: admin reject (skip 2-stage gate). + var admin = await _fx.CreateUserAsync($"adm-rej-{Guid.NewGuid():N}@test", "Admin", null, ["Admin"]); + await _service.TransitionAsync( + pe, PurchaseEvaluationPhase.TuChoi, admin.Id, ["Admin"], + ApprovalDecision.Reject, "reject test"); + + // Assert: phase = DangSoanThao, RejectedFromPhase = ChoPurchasing, + // N-stage rows tại ChoPurchasing đã clear. + var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + fresh.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao); + fresh.RejectedFromPhase.Should().Be(PurchaseEvaluationPhase.ChoPurchasing); + + var rowsAfterReject = await _db.PurchaseEvaluationDepartmentApprovals.AsNoTracking() + .Where(a => a.PurchaseEvaluationId == pe.Id && a.InnerStepId != null).CountAsync(); + rowsAfterReject.Should().Be(0); + } + + [Fact] + public async Task LegacyFallback_NoInnerSteps_Uses_2Stage_Logic() + { + // Arrange: KHÔNG pin WorkflowDefinitionId → service fallback hardcoded + // policy → no inner steps → legacy 2-stage logic kick in. + var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing, workflowDefinitionId: null); + + var nv = await _fx.CreateUserAsync( + $"nv-legacy-{Guid.NewGuid():N}@test", "NV legacy", + _deptPro, ["Procurement"]); // KHÔNG có positionLevel — legacy không cần + + // Act: NV approve → legacy 2-stage Stage=Review row. + await _service.TransitionAsync( + pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"], + ApprovalDecision.Approve, "legacy review"); + + // Assert: phase chưa đổi (NV chỉ Review), 1 row InnerStepId=NULL (legacy). + var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoPurchasing); + + var rows = await _db.PurchaseEvaluationDepartmentApprovals.AsNoTracking() + .Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync(); + rows.Should().HaveCount(1); + rows[0].InnerStepId.Should().BeNull(); + rows[0].Stage.Should().Be(ApprovalStage.Review); + } +}