diff --git a/src/Backend/SolutionErp.Application/SolutionErp.Application.csproj b/src/Backend/SolutionErp.Application/SolutionErp.Application.csproj index eaeda99..94d9b30 100644 --- a/src/Backend/SolutionErp.Application/SolutionErp.Application.csproj +++ b/src/Backend/SolutionErp.Application/SolutionErp.Application.csproj @@ -18,4 +18,9 @@ enable + + + + + diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PurchaseEvaluationDraftGuardTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PurchaseEvaluationDraftGuardTests.cs new file mode 100644 index 0000000..b197ad2 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/PurchaseEvaluationDraftGuardTests.cs @@ -0,0 +1,231 @@ +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Application.PurchaseEvaluations; +using SolutionErp.Domain.ApprovalWorkflowsV2; +using SolutionErp.Domain.Identity; +using SolutionErp.Domain.PurchaseEvaluations; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Application; + +// Plan C task 3 catch-up cho S21 t4-t5 helper `EnsureEditableForDetailsAsync`: +// F3 (Mig 28 → Mig 29) — gating Section 2 (Detail + NCC + Báo giá) edit access. +// 3 scenario decision branch: +// 1. Drafter scope: Phase ∈ {DangSoanThao, TraLai} → accept any caller +// 2. F3 Approver scope: Phase=ChoDuyet + level.AllowApproverEditDetails=true +// + actor.Id == level.ApproverUserId → accept +// 3. Admin bypass: Phase=ChoDuyet + role contains Admin → accept (skip flag check) +// Otherwise throw ConflictException / ForbiddenException. +public class PurchaseEvaluationDraftGuardTests +{ + private sealed class FakeCurrentUser : ICurrentUser + { + public Guid? UserId { get; init; } + public string? Email { get; init; } + public string? FullName { get; init; } + public IReadOnlyList Roles { get; init; } = Array.Empty(); + public bool IsAuthenticated => UserId is not null; + } + + private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep step, ApprovalWorkflowLevel level)> + SeedWorkflowAsync(TestApplicationDbContext db, Guid approverUserId, bool allowApproverEdit = false) + { + var wf = new ApprovalWorkflow + { + Id = Guid.NewGuid(), + Code = "QT-GUARD-001", + Version = 1, + Name = "Guard test workflow", + ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc, + IsActive = true, + IsUserSelectable = true, + }; + var step = new ApprovalWorkflowStep + { + Id = Guid.NewGuid(), + ApprovalWorkflowId = wf.Id, + Order = 1, + DepartmentId = null, + Name = "Bước 1", + }; + var level = new ApprovalWorkflowLevel + { + Id = Guid.NewGuid(), + ApprovalWorkflowStepId = step.Id, + Order = 1, + ApproverUserId = approverUserId, + AllowApproverEditDetails = allowApproverEdit, + }; + db.ApprovalWorkflows.Add(wf); + db.ApprovalWorkflowSteps.Add(step); + db.ApprovalWorkflowLevels.Add(level); + await db.SaveChangesAsync(CancellationToken.None); + return (wf, step, level); + } + + private static PurchaseEvaluation BuildPe( + PurchaseEvaluationPhase phase, + Guid? workflowId = null, + int? stepIdx = null, + int? levelOrder = null, + string code = "PE-G-001") + { + return new PurchaseEvaluation + { + Id = Guid.NewGuid(), + Type = PurchaseEvaluationType.DuyetNcc, + Phase = phase, + MaPhieu = code, + TenGoiThau = "Test guard", + ProjectId = Guid.NewGuid(), + DrafterUserId = Guid.NewGuid(), + ApprovalWorkflowId = workflowId, + CurrentWorkflowStepIndex = stepIdx, + CurrentApprovalLevelOrder = levelOrder, + }; + } + + // ===== Scenario 1: Drafter scope ===== + + [Fact] + public async Task DraftScope_DangSoanThao_AnyCaller_ReturnsPe() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var pe = BuildPe(PurchaseEvaluationPhase.DangSoanThao); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + var anyCaller = new FakeCurrentUser { UserId = Guid.NewGuid(), Roles = new[] { AppRoles.Drafter } }; + + var result = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync( + db, pe.Id, anyCaller, CancellationToken.None); + + result.Id.Should().Be(pe.Id); + } + + [Fact] + public async Task DraftScope_TraLai_AnyCaller_ReturnsPe() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var pe = BuildPe(PurchaseEvaluationPhase.TraLai, code: "PE-G-002"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + var anyCaller = new FakeCurrentUser { UserId = Guid.NewGuid(), Roles = new[] { AppRoles.Drafter } }; + + var result = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync( + db, pe.Id, anyCaller, CancellationToken.None); + + result.Id.Should().Be(pe.Id); + } + + // ===== Scenario 2: F3 Approver scope ===== + + [Fact] + public async Task ApproverScope_ChoDuyet_FlagOn_ActorMatches_ReturnsPe() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var approverUser = await fix.CreateUserAsync("approver-g1@test.local", "Approver G1", departmentId: null, roles: new[] { AppRoles.CostControl }); + var (wf, _, _) = await SeedWorkflowAsync(db, approverUser.Id, allowApproverEdit: true); + var pe = BuildPe(PurchaseEvaluationPhase.ChoDuyet, wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-G-003"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + var approver = new FakeCurrentUser { UserId = approverUser.Id, Roles = new[] { AppRoles.CostControl } }; + + var result = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync( + db, pe.Id, approver, CancellationToken.None); + + result.Id.Should().Be(pe.Id); + } + + [Fact] + public async Task ApproverScope_ChoDuyet_FlagOff_NonAdmin_Throws() + { + // F3 disabled trên Level slot → throw ConflictException. + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var approverUser = await fix.CreateUserAsync("approver-g2@test.local", "Approver G2", departmentId: null, roles: new[] { AppRoles.CostControl }); + var (wf, _, _) = await SeedWorkflowAsync(db, approverUser.Id, allowApproverEdit: false); + var pe = BuildPe(PurchaseEvaluationPhase.ChoDuyet, wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-G-004"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + var approver = new FakeCurrentUser { UserId = approverUser.Id, Roles = new[] { AppRoles.CostControl } }; + + var act = async () => await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync( + db, pe.Id, approver, CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*không được cấp quyền chỉnh sửa Section 2*"); + } + + [Fact] + public async Task ApproverScope_ChoDuyet_FlagOn_ActorMismatch_ThrowsForbidden() + { + // Khác user gọi (không phải slot ApproverUserId) → Forbidden. + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var approverUser = await fix.CreateUserAsync("approver-g3@test.local", "Approver G3", departmentId: null, roles: new[] { AppRoles.CostControl }); + var (wf, _, _) = await SeedWorkflowAsync(db, approverUser.Id, allowApproverEdit: true); + var pe = BuildPe(PurchaseEvaluationPhase.ChoDuyet, wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-G-005"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + var someoneElse = new FakeCurrentUser { UserId = Guid.NewGuid(), Roles = new[] { AppRoles.CostControl } }; + + var act = async () => await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync( + db, pe.Id, someoneElse, CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*Chỉ NV phụ trách*"); + } + + // ===== Scenario 3: Admin bypass ===== + + [Fact] + public async Task AdminBypass_ChoDuyet_FlagOff_ReturnsPe() + { + // Admin role bypass Allow* flag check + actor match — vẫn được edit. + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var approverUser = await fix.CreateUserAsync("approver-g4@test.local", "Approver G4", departmentId: null, roles: new[] { AppRoles.CostControl }); + var (wf, _, _) = await SeedWorkflowAsync(db, approverUser.Id, allowApproverEdit: false); + var pe = BuildPe(PurchaseEvaluationPhase.ChoDuyet, wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-G-006"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + var admin = new FakeCurrentUser { UserId = Guid.NewGuid(), Roles = new[] { AppRoles.Admin } }; + + var result = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync( + db, pe.Id, admin, CancellationToken.None); + + result.Id.Should().Be(pe.Id); + } + + // ===== Other phase locked ===== + + [Fact] + public async Task DaDuyet_AnyCaller_Throws() + { + // Phase terminal (DaDuyet/TuChoi/DaPhatHanh) — không ai edit được, kể cả admin trừ khi + // tạo helper bypass riêng. Hiện EnsureEditable không có admin bypass cho non-ChoDuyet. + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var pe = BuildPe(PurchaseEvaluationPhase.DaDuyet, code: "PE-G-007"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + var admin = new FakeCurrentUser { UserId = Guid.NewGuid(), Roles = new[] { AppRoles.Admin } }; + + var act = async () => await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync( + db, pe.Id, admin, CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*Phase=DaDuyet*"); + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceReturnModeTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceReturnModeTests.cs new file mode 100644 index 0000000..9061759 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceReturnModeTests.cs @@ -0,0 +1,378 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.Notifications; +using SolutionErp.Application.PurchaseEvaluations.Services; // WorkflowReturnMode enum +using SolutionErp.Domain.ApprovalWorkflowsV2; +using SolutionErp.Domain.Common; +using SolutionErp.Domain.Contracts; // ApprovalDecision shared +using SolutionErp.Domain.Identity; +using SolutionErp.Domain.Notifications; +using SolutionErp.Domain.PurchaseEvaluations; +using SolutionErp.Infrastructure.Services; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Services; + +// Plan C task 1-2 catch-up cho S21 t4-t5 feature: +// - ApplyReturnModeAsync 4 mode đọc level.Allow* per-NV (Mig 29 refactor từ workflow-level Mig 28) +// - skipToFinal đọc user.AllowDrafterSkipToFinal per-Drafter (Mig 29 split scope theo role) +// +// Focus: defensive boundary check + admin bypass invariant. KHÔNG cover toàn bộ +// edge case (Bước 1 Cấp 1 fallback, Assignee runtime pick, V1 legacy fallback) — +// defer khi UAT lộ regression cụ thể. +public class PurchaseEvaluationWorkflowServiceReturnModeTests +{ + private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix, TestApplicationDbContext db, FixedDateTime dt) + CreateService() + { + var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + var dt = new FixedDateTime(new DateTime(2026, 5, 13, 0, 0, 0, DateTimeKind.Utc)); + var notify = new NoOpNotificationService(); + var svc = new PurchaseEvaluationWorkflowService(db, dt, notify, um); + return (svc, fix, db, dt); + } + + // Workflow setup: 1 Bước (1 Step) — 2 Cấp (2 Levels) — mỗi Cấp 1 Approver. + // Mặc định mọi Allow* = false trên Level slot (admin opt-in pattern Mig 29). + // ApproverUserId mặc định = approverId truyền vào (caller có thể override). + private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep step, ApprovalWorkflowLevel l1, ApprovalWorkflowLevel l2)> + SeedWorkflowAsync( + TestApplicationDbContext db, + Guid approver1UserId, + Guid approver2UserId, + bool allowReturnOneLevelL2 = false, + bool allowReturnToDrafterL2 = false, + bool allowApproverEditL2 = false) + { + var wf = new ApprovalWorkflow + { + Id = Guid.NewGuid(), + Code = "QT-TEST-001", + Version = 1, + Name = "Test Workflow per-NV", + ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc, + IsActive = true, + IsUserSelectable = true, + }; + var step = new ApprovalWorkflowStep + { + Id = Guid.NewGuid(), + ApprovalWorkflowId = wf.Id, + Order = 1, + DepartmentId = null, // Step.DepartmentId là hint nullable — skip FK seeding Department trong test + Name = "Bước 1 CCM", + }; + var l1 = new ApprovalWorkflowLevel + { + Id = Guid.NewGuid(), + ApprovalWorkflowStepId = step.Id, + Order = 1, + ApproverUserId = approver1UserId, + // L1 defaults: Allow* = false (test sad path easier) + }; + var l2 = new ApprovalWorkflowLevel + { + Id = Guid.NewGuid(), + ApprovalWorkflowStepId = step.Id, + Order = 2, + ApproverUserId = approver2UserId, + AllowReturnOneLevel = allowReturnOneLevelL2, + AllowReturnToDrafter = allowReturnToDrafterL2, + AllowApproverEditDetails = allowApproverEditL2, + }; + db.ApprovalWorkflows.Add(wf); + db.ApprovalWorkflowSteps.Add(step); + db.ApprovalWorkflowLevels.Add(l1); + db.ApprovalWorkflowLevels.Add(l2); + await db.SaveChangesAsync(CancellationToken.None); + return (wf, step, l1, l2); + } + + private static PurchaseEvaluation BuildPeAtLevel2(Guid workflowId, Guid drafterId, string code = "PE-RM-001") + { + return new PurchaseEvaluation + { + Id = Guid.NewGuid(), + Type = PurchaseEvaluationType.DuyetNcc, + Phase = PurchaseEvaluationPhase.ChoDuyet, + MaPhieu = code, + TenGoiThau = "Test return mode", + ProjectId = Guid.NewGuid(), + DrafterUserId = drafterId, + ApprovalWorkflowId = workflowId, + CurrentWorkflowStepIndex = 0, // Bước 1 (Step Order=1) + CurrentApprovalLevelOrder = 2, // Cấp 2 đang chờ duyệt + }; + } + + // ============ Task 1: ApplyReturnModeAsync ============ + + private static async Task<(User a1, User a2)> SeedApproversAsync(IdentityFixture fix, string suffix) + { + var a1 = await fix.CreateUserAsync($"approver1-{suffix}@test.local", $"Approver1 {suffix}", departmentId: null, roles: new[] { AppRoles.CostControl }); + var a2 = await fix.CreateUserAsync($"approver2-{suffix}@test.local", $"Approver2 {suffix}", departmentId: null, roles: new[] { AppRoles.CostControl }); + return (a1, a2); + } + + [Fact] + public async Task ReturnMode_Drafter_AllowedByLevel_SetsPhaseTraLaiAndClearsPointer() + { + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var (a1, a2) = await SeedApproversAsync(fix, "rm1"); + var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id, allowReturnToDrafterL2: true); + var pe = BuildPeAtLevel2(wf.Id, drafterId: Guid.NewGuid()); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + await svc.TransitionAsync( + evaluation: pe, + targetPhase: PurchaseEvaluationPhase.TraLai, + actorUserId: a2.Id, + actorRoles: new[] { AppRoles.CostControl }, + decision: ApprovalDecision.Reject, + comment: "trả về drafter sửa", + returnMode: WorkflowReturnMode.Drafter, + ct: CancellationToken.None); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.TraLai); + pe.CurrentWorkflowStepIndex.Should().BeNull("Drafter mode clear step pointer"); + pe.CurrentApprovalLevelOrder.Should().BeNull("Drafter mode clear level pointer"); + pe.SlaDeadline.Should().BeNull(); + } + } + + [Fact] + public async Task ReturnMode_Drafter_DeniedByLevel_NonAdmin_Throws() + { + // Level Allow*=false (default) + non-admin → throw ConflictException. + // Pattern: admin Designer KHÔNG tick flag cho Level slot này → Approver + // không được trả về Drafter mode (phải dùng mode khác hoặc Trả lại admin). + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var (a1, a2) = await SeedApproversAsync(fix, "rm2"); + var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id, allowReturnToDrafterL2: false); + var pe = BuildPeAtLevel2(wf.Id, drafterId: Guid.NewGuid(), code: "PE-RM-002"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + var act = async () => await svc.TransitionAsync( + evaluation: pe, + targetPhase: PurchaseEvaluationPhase.TraLai, + actorUserId: a2.Id, + actorRoles: new[] { AppRoles.CostControl }, + decision: ApprovalDecision.Reject, + comment: "test denied", + returnMode: WorkflowReturnMode.Drafter, + ct: CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*Cấp Approver hiện tại không bật mode*Drafter*"); + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "Guard chặn trước mutate"); + } + } + + [Fact] + public async Task ReturnMode_OneLevel_AllowedByLevel_LowersLevelPointer() + { + // Level 2 → Level 1 trong cùng Step (peer review chain). + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var (a1, a2) = await SeedApproversAsync(fix, "rm3"); + var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id, allowReturnOneLevelL2: true); + var pe = BuildPeAtLevel2(wf.Id, drafterId: Guid.NewGuid(), code: "PE-RM-003"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + await svc.TransitionAsync( + evaluation: pe, + targetPhase: PurchaseEvaluationPhase.TraLai, + actorUserId: a2.Id, + actorRoles: new[] { AppRoles.CostControl }, + decision: ApprovalDecision.Reject, + comment: "trả về 1 Cấp", + returnMode: WorkflowReturnMode.OneLevel, + ct: CancellationToken.None); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, + "OneLevel giữ ChoDuyet (peer review chain), KHÔNG về TraLai"); + pe.CurrentApprovalLevelOrder.Should().Be(1, "Lùi 1 Cấp (từ 2 → 1)"); + pe.CurrentWorkflowStepIndex.Should().Be(0, "Giữ Step hiện tại"); + } + } + + [Fact] + public async Task ReturnMode_OneLevel_DeniedByLevel_AdminBypass_Succeeds() + { + // Admin override workflow.Allow* check — vẫn được trả lại dù slot disabled. + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var (a1, a2) = await SeedApproversAsync(fix, "rm4"); + var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id, allowReturnOneLevelL2: false); + var pe = BuildPeAtLevel2(wf.Id, drafterId: Guid.NewGuid(), code: "PE-RM-004"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + await svc.TransitionAsync( + evaluation: pe, + targetPhase: PurchaseEvaluationPhase.TraLai, + actorUserId: a2.Id, + actorRoles: new[] { AppRoles.Admin }, + decision: ApprovalDecision.Reject, + comment: "admin override", + returnMode: WorkflowReturnMode.OneLevel, + ct: CancellationToken.None); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet); + pe.CurrentApprovalLevelOrder.Should().Be(1, + "Admin bypass Allow* flag — vẫn lùi pointer"); + } + } + + // ============ Task 2: skipToFinal (Drafter trình branch) ============ + + [Fact] + public async Task SkipToFinal_DrafterAllowed_SetsPointerToFinalLevel() + { + // Drafter user có AllowDrafterSkipToFinal=true → init pointer cuối step + cuối level. + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var (a1, a2) = await SeedApproversAsync(fix, "skip1"); + var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id); + var drafter = await fix.CreateUserAsync( + "drafter.skip@test.local", "Drafter Skip", departmentId: null, + roles: new[] { AppRoles.Drafter }); + drafter.AllowDrafterSkipToFinal = true; + await fix.Services.GetRequiredService>().UpdateAsync(drafter); + + var pe = new PurchaseEvaluation + { + Id = Guid.NewGuid(), + Type = PurchaseEvaluationType.DuyetNcc, + Phase = PurchaseEvaluationPhase.DangSoanThao, + MaPhieu = "PE-SKIP-001", + TenGoiThau = "Skip to final", + ProjectId = Guid.NewGuid(), + DrafterUserId = drafter.Id, + ApprovalWorkflowId = wf.Id, + }; + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + await svc.TransitionAsync( + evaluation: pe, + targetPhase: PurchaseEvaluationPhase.ChoDuyet, + actorUserId: drafter.Id, + actorRoles: new[] { AppRoles.Drafter }, + decision: ApprovalDecision.Approve, + comment: "gửi thẳng cấp cuối", + skipToFinal: true, + ct: CancellationToken.None); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet); + pe.CurrentWorkflowStepIndex.Should().Be(0, "Step duy nhất (chỉ 1 Step) = index 0"); + pe.CurrentApprovalLevelOrder.Should().Be(2, + "Final Level Order = 2 (Cấp cuối Bước cuối)"); + } + } + + [Fact] + public async Task SkipToFinal_DrafterDenied_NonAdmin_Throws() + { + // Drafter user có AllowDrafterSkipToFinal=false (default) + non-admin → throw. + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var (a1, a2) = await SeedApproversAsync(fix, "skip2"); + var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id); + var drafter = await fix.CreateUserAsync( + "drafter.noskip@test.local", "Drafter NoSkip", departmentId: null, + roles: new[] { AppRoles.Drafter }); + // drafter.AllowDrafterSkipToFinal = false (default) + + var pe = new PurchaseEvaluation + { + Id = Guid.NewGuid(), + Type = PurchaseEvaluationType.DuyetNcc, + Phase = PurchaseEvaluationPhase.DangSoanThao, + MaPhieu = "PE-SKIP-002", + TenGoiThau = "Skip denied", + ProjectId = Guid.NewGuid(), + DrafterUserId = drafter.Id, + ApprovalWorkflowId = wf.Id, + }; + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + var act = async () => await svc.TransitionAsync( + evaluation: pe, + targetPhase: PurchaseEvaluationPhase.ChoDuyet, + actorUserId: drafter.Id, + actorRoles: new[] { AppRoles.Drafter }, + decision: ApprovalDecision.Approve, + comment: "test denied", + skipToFinal: true, + ct: CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*không được phép gửi thẳng Cấp cuối*"); + + // Service mutate Phase=ChoDuyet TRƯỚC khi validate skipToFinal flag, + // throw chặn SaveChangesAsync → DB không persist. Test focus contract + // throw, không assert in-memory rollback (note: nếu future refactor + // move validate trước mutate, test này vẫn pass). + pe.CurrentWorkflowStepIndex.Should().BeNull("Skip flow throw trước khi init pointer"); + pe.CurrentApprovalLevelOrder.Should().BeNull("Pointer chưa init khi throw"); + } + } + + [Fact] + public async Task SkipToFinal_AdminBypass_Succeeds() + { + // Admin role bypass user.AllowDrafterSkipToFinal flag check. + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var (a1, a2) = await SeedApproversAsync(fix, "skip3"); + var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id); + var adminUser = await fix.CreateUserAsync( + "admin.skip@test.local", "Admin Skip", departmentId: null, + roles: new[] { AppRoles.Admin }); + + var pe = new PurchaseEvaluation + { + Id = Guid.NewGuid(), + Type = PurchaseEvaluationType.DuyetNcc, + Phase = PurchaseEvaluationPhase.DangSoanThao, + MaPhieu = "PE-SKIP-003", + TenGoiThau = "Admin skip", + ProjectId = Guid.NewGuid(), + DrafterUserId = adminUser.Id, + ApprovalWorkflowId = wf.Id, + }; + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + await svc.TransitionAsync( + evaluation: pe, + targetPhase: PurchaseEvaluationPhase.ChoDuyet, + actorUserId: adminUser.Id, + actorRoles: new[] { AppRoles.Admin }, + decision: ApprovalDecision.Approve, + comment: "admin gửi thẳng", + skipToFinal: true, + ct: CancellationToken.None); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet); + pe.CurrentApprovalLevelOrder.Should().Be(2, "Admin bypass + skip → pointer cuối"); + } + } +}