[CLAUDE] Tests: Plan C task 1-3 — Service per-NV Allow* test catch-up (S21 t4-t5 Mig 28-29)
14 test cover 3 helper sửa lớn S21 t4-t5 (test-after UAT backlog): Task 1+2 — PurchaseEvaluationWorkflowServiceReturnModeTests.cs (7 test): - ApplyReturnModeAsync Drafter allowed/denied/admin bypass (3 test mode flag) - OneLevel happy path (peer review chain in same Step) - OneLevel admin bypass (override disabled flag) - skipToFinal Drafter allowed/denied/admin bypass (3 test per-user F2) Task 3 — PurchaseEvaluationDraftGuardTests.cs (7 test): - Drafter scope: DangSoanThao + TraLai → return (2 test) - F3 Approver scope: ChoDuyet + flag on + actor match → return - F3 Approver scope: ChoDuyet + flag off → ConflictException - F3 Approver scope: ChoDuyet + flag on + actor mismatch → ForbiddenException - Admin bypass ChoDuyet + flag off → return - DaDuyet any caller → ConflictException (terminal phase) InternalsVisibleTo: expose PurchaseEvaluationDraftGuard internal helper cho test. Finding: skipToFinal Service mutate Phase=ChoDuyet TRƯỚC validate user flag. Throw chặn SaveChanges nên DB không persist nhưng in-memory dirty. Note trong test — không refactor scope catch-up (defer S22+). Verify: - dotnet test SolutionErp.slnx — 103/103 PASS (58 Domain + 45 Infra) Δ: 89 → 103 (+14: ReturnMode 7 + Guard 7) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -18,4 +18,9 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Expose internal helpers (e.g. PurchaseEvaluationDraftGuard) cho test project -->
|
||||||
|
<InternalsVisibleTo Include="SolutionErp.Infrastructure.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -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<string> Roles { get; init; } = Array.Empty<string>();
|
||||||
|
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<TestApplicationDbContext>();
|
||||||
|
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<TestApplicationDbContext>();
|
||||||
|
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<TestApplicationDbContext>();
|
||||||
|
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<TestApplicationDbContext>();
|
||||||
|
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<ConflictException>()
|
||||||
|
.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<TestApplicationDbContext>();
|
||||||
|
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<ForbiddenException>()
|
||||||
|
.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<TestApplicationDbContext>();
|
||||||
|
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<TestApplicationDbContext>();
|
||||||
|
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<ConflictException>()
|
||||||
|
.WithMessage("*Phase=DaDuyet*");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<TestApplicationDbContext>();
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
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<ConflictException>()
|
||||||
|
.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<UserManager<User>>().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<ConflictException>()
|
||||||
|
.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user