[CLAUDE] Tests: PE N-stage workflow approval (6 test) + IdentityFixture extend (Chunk D)
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-07 18:23:59 +07:00
parent 0c62e241d0
commit 3d76c6bc0c
2 changed files with 328 additions and 2 deletions

View File

@ -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<User> CreateUserAsync(
string email,
string fullName,
Guid? departmentId,
string[] roles,
bool canBypassReview = false)
bool canBypassReview = false,
PositionLevel? positionLevel = null)
{
var um = Services.GetRequiredService<UserManager<User>>();
var rm = Services.GetRequiredService<RoleManager<Role>>();
@ -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");

View File

@ -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<IdentityFixture>
{
private readonly IdentityFixture _fx;
private readonly TestApplicationDbContext _db;
private readonly UserManager<User> _userManager;
private readonly PurchaseEvaluationWorkflowService _service;
private readonly Guid _deptPro;
private readonly Guid _deptCcm;
public PeNStageApprovalTests(IdentityFixture fx)
{
_fx = fx;
_db = fx.Services.GetRequiredService<TestApplicationDbContext>();
_userManager = fx.Services.GetRequiredService<UserManager<User>>();
_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<Guid> 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<PurchaseEvaluation> 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<ForbiddenException>();
}
[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);
}
}