[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:
@ -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*");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user