[CLAUDE] Tests: Contract N-stage approval 6 test mirror PE (Chunk D)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m1s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m1s
ContractNStageApprovalTests.cs (NEW, 6 test) cover N-stage logic Mig 20 mirror PeNStageApprovalTests pattern: 1. NStage_FirstInner_NV_Approve_Blocks_Phase_Transition NV cấp 1 → 1 row InnerStepId set, phase chưa đổi. 2. NStage_All_3_Levels_Sequential_Pass_Allow_Phase_Transition NV → PP → TP duyệt lần lượt → 3 rows + phase chuyển. 3. NStage_TP_Bypass_Skips_Lower_Levels_Same_Dept TP có CanBypassReview → 1 transition tạo 3 rows (NV+PP IsBypassed, TP exact match). 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. Admin reject → DangSoanThao + RejectedFromPhase set + N-stage rows cleared. 6. LegacyFallback_NoInnerSteps_Uses_2Stage_Logic Contract không pin WorkflowDefinitionId → fallback hardcoded Standard policy → no inner steps → legacy 2-stage Stage=Review row. Phase pair DangKiemTraCCM → DangTrinhKy + role CostControl khớp Standard.Transitions (DangGopY → DangDamPhan trong test ban đầu fail vì policy chỉ cho [Drafter, DeptManager]; switched to phase pair work). Helper SeedWorkflowDefinitionAsync 2 step adjacent (DangGopY + DangDamPhan) + SeedContractAsync với Project + Supplier seed cho FK. FakeChangelogService + FakeContractCodeGenerator stubs (no-op cho tests không cần verify changelog/codegen path). Verify: 89 → **95 test pass** (54 Domain + 41 Infra: 17 codegen + 6 PE WF versioning + 6 PE 2-stage + 6 PE N-stage + 6 Contract N-stage). Pending Chunk E: API check (Workflows controller likely auto-bind via DTO, skip nếu OK). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,350 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.Contracts.Services;
|
||||||
|
using SolutionErp.Application.Notifications;
|
||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
using SolutionErp.Domain.Contracts;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.Notifications;
|
||||||
|
using SolutionErp.Infrastructure.Services;
|
||||||
|
using SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Services;
|
||||||
|
|
||||||
|
// Tests cho N-stage department approval logic ở ContractWorkflowService (Mig 20).
|
||||||
|
// Mirror PeNStageApprovalTests pattern. Cover Phòng × PositionLevel sequential
|
||||||
|
// trong cùng phase + bypass cùng dept + reject reset + legacy fallback.
|
||||||
|
public class ContractNStageApprovalTests : IClassFixture<IdentityFixture>
|
||||||
|
{
|
||||||
|
private readonly IdentityFixture _fx;
|
||||||
|
private readonly TestApplicationDbContext _db;
|
||||||
|
private readonly UserManager<User> _userManager;
|
||||||
|
private readonly ContractWorkflowService _service;
|
||||||
|
private readonly Guid _deptPro;
|
||||||
|
private readonly Guid _deptCcm;
|
||||||
|
|
||||||
|
public ContractNStageApprovalTests(IdentityFixture fx)
|
||||||
|
{
|
||||||
|
_fx = fx;
|
||||||
|
_db = fx.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
_userManager = fx.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
|
||||||
|
_deptPro = SeedDept("PRO-CTR-NS", "Phòng Cung ứng (CTR-NS)");
|
||||||
|
_deptCcm = SeedDept("CCM-CTR-NS", "Phòng Kiểm soát (CTR-NS)");
|
||||||
|
|
||||||
|
var clock = new FixedDateTime(new DateTime(2026, 5, 7, 11, 0, 0, DateTimeKind.Utc));
|
||||||
|
var fakeNotifications = new FakeNotificationService();
|
||||||
|
var fakeChangelog = new FakeChangelogService();
|
||||||
|
var fakeCodeGen = new FakeContractCodeGenerator();
|
||||||
|
|
||||||
|
_service = new ContractWorkflowService(
|
||||||
|
_db, fakeCodeGen, clock, fakeNotifications, fakeChangelog, _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: DangGopY (current, có inner steps) → DangDamPhan (next).
|
||||||
|
// FromDefinition build transition (DangGopY → DangDamPhan) từ step[1].Approvers role.
|
||||||
|
var def = new WorkflowDefinition
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = $"NS-CTR-{Guid.NewGuid():N}".Substring(0, 18),
|
||||||
|
Version = 1,
|
||||||
|
ContractType = ContractType.HopDongThauPhu,
|
||||||
|
Name = "N-stage Contract test workflow",
|
||||||
|
IsActive = true,
|
||||||
|
ActivatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
var step1 = new WorkflowStep
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Order = 1,
|
||||||
|
Phase = ContractPhase.DangGopY,
|
||||||
|
Name = "Góp ý",
|
||||||
|
Approvers =
|
||||||
|
{
|
||||||
|
new WorkflowStepApprover
|
||||||
|
{
|
||||||
|
Kind = WorkflowApproverKind.Role,
|
||||||
|
AssignmentValue = "Procurement",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (int i = 0; i < innerSteps.Length; i++)
|
||||||
|
{
|
||||||
|
step1.InnerSteps.Add(new WorkflowStepInnerStep
|
||||||
|
{
|
||||||
|
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 (DangGopY → DangDamPhan).
|
||||||
|
def.Steps.Add(new WorkflowStep
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Order = 2,
|
||||||
|
Phase = ContractPhase.DangDamPhan,
|
||||||
|
Name = "Đàm phán",
|
||||||
|
Approvers =
|
||||||
|
{
|
||||||
|
new WorkflowStepApprover
|
||||||
|
{
|
||||||
|
Kind = WorkflowApproverKind.Role,
|
||||||
|
AssignmentValue = "Procurement",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
_db.WorkflowDefinitions.Add(def);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return def.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Contract> SeedContractAsync(
|
||||||
|
ContractPhase phase,
|
||||||
|
Guid? workflowDefinitionId = null)
|
||||||
|
{
|
||||||
|
var pid = Guid.NewGuid();
|
||||||
|
if (!_db.Projects.Any(p => p.Id == pid))
|
||||||
|
{
|
||||||
|
_db.Projects.Add(new SolutionErp.Domain.Master.Project
|
||||||
|
{
|
||||||
|
Id = pid,
|
||||||
|
Code = $"PRJ-CTR-{Random.Shared.Next(10000):D4}",
|
||||||
|
Name = "Test project CTR-NS",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var sid = Guid.NewGuid();
|
||||||
|
if (!_db.Suppliers.Any(s => s.Id == sid))
|
||||||
|
{
|
||||||
|
_db.Suppliers.Add(new SolutionErp.Domain.Master.Supplier
|
||||||
|
{
|
||||||
|
Id = sid,
|
||||||
|
Code = $"NCC-{Random.Shared.Next(10000):D4}",
|
||||||
|
Name = "Test supplier",
|
||||||
|
Type = SolutionErp.Domain.Master.SupplierType.NhaCungCap,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var contract = new Contract
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = ContractType.HopDongThauPhu,
|
||||||
|
Phase = phase,
|
||||||
|
TenHopDong = "Test HĐ N-stage",
|
||||||
|
ProjectId = pid,
|
||||||
|
SupplierId = sid,
|
||||||
|
WorkflowDefinitionId = workflowDefinitionId,
|
||||||
|
};
|
||||||
|
_db.Contracts.Add(contract);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NStage_FirstInner_NV_Approve_Blocks_Phase_Transition()
|
||||||
|
{
|
||||||
|
var defId = await SeedWorkflowDefinitionAsync(
|
||||||
|
(_deptPro, PositionLevel.NhanVien),
|
||||||
|
(_deptPro, PositionLevel.PhoPhong),
|
||||||
|
(_deptPro, PositionLevel.TruongPhong));
|
||||||
|
var contract = await SeedContractAsync(ContractPhase.DangGopY, defId);
|
||||||
|
|
||||||
|
var nv = await _fx.CreateUserAsync(
|
||||||
|
$"nv-pro-ctr-{Guid.NewGuid():N}@test", "NV PRO Contract",
|
||||||
|
_deptPro, ["Procurement"], positionLevel: PositionLevel.NhanVien);
|
||||||
|
|
||||||
|
await _service.TransitionAsync(
|
||||||
|
contract, ContractPhase.DangDamPhan, nv.Id, ["Procurement"],
|
||||||
|
ApprovalDecision.Approve, "duyệt NV");
|
||||||
|
|
||||||
|
var fresh = await _db.Contracts.AsNoTracking().FirstAsync(x => x.Id == contract.Id);
|
||||||
|
fresh.Phase.Should().Be(ContractPhase.DangGopY);
|
||||||
|
|
||||||
|
var rows = await _db.ContractDepartmentApprovals.AsNoTracking()
|
||||||
|
.Where(a => a.ContractId == contract.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()
|
||||||
|
{
|
||||||
|
var defId = await SeedWorkflowDefinitionAsync(
|
||||||
|
(_deptPro, PositionLevel.NhanVien),
|
||||||
|
(_deptPro, PositionLevel.PhoPhong),
|
||||||
|
(_deptPro, PositionLevel.TruongPhong));
|
||||||
|
var contract = await SeedContractAsync(ContractPhase.DangGopY, 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);
|
||||||
|
|
||||||
|
await _service.TransitionAsync(contract, ContractPhase.DangDamPhan, nv.Id, ["Procurement"], ApprovalDecision.Approve, "NV");
|
||||||
|
contract = await _db.Contracts.FirstAsync(x => x.Id == contract.Id);
|
||||||
|
await _service.TransitionAsync(contract, ContractPhase.DangDamPhan, pp.Id, ["Procurement"], ApprovalDecision.Approve, "PP");
|
||||||
|
contract = await _db.Contracts.FirstAsync(x => x.Id == contract.Id);
|
||||||
|
await _service.TransitionAsync(contract, ContractPhase.DangDamPhan, tp.Id, ["Procurement"], ApprovalDecision.Approve, "TP");
|
||||||
|
|
||||||
|
var fresh = await _db.Contracts.AsNoTracking().FirstAsync(x => x.Id == contract.Id);
|
||||||
|
fresh.Phase.Should().Be(ContractPhase.DangDamPhan);
|
||||||
|
|
||||||
|
var rows = await _db.ContractDepartmentApprovals.AsNoTracking()
|
||||||
|
.Where(a => a.ContractId == contract.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()
|
||||||
|
{
|
||||||
|
var defId = await SeedWorkflowDefinitionAsync(
|
||||||
|
(_deptPro, PositionLevel.NhanVien),
|
||||||
|
(_deptPro, PositionLevel.PhoPhong),
|
||||||
|
(_deptPro, PositionLevel.TruongPhong));
|
||||||
|
var contract = await SeedContractAsync(ContractPhase.DangGopY, defId);
|
||||||
|
|
||||||
|
var tp = await _fx.CreateUserAsync(
|
||||||
|
$"tp-bypass-{Guid.NewGuid():N}@test", "TP bypass",
|
||||||
|
_deptPro, ["Procurement"],
|
||||||
|
canBypassReview: true, positionLevel: PositionLevel.TruongPhong);
|
||||||
|
|
||||||
|
await _service.TransitionAsync(
|
||||||
|
contract, ContractPhase.DangDamPhan, tp.Id, ["Procurement"],
|
||||||
|
ApprovalDecision.Approve, "TP bypass");
|
||||||
|
|
||||||
|
var fresh = await _db.Contracts.AsNoTracking().FirstAsync(x => x.Id == contract.Id);
|
||||||
|
fresh.Phase.Should().Be(ContractPhase.DangDamPhan);
|
||||||
|
|
||||||
|
var rows = await _db.ContractDepartmentApprovals.AsNoTracking()
|
||||||
|
.Where(a => a.ContractId == contract.Id && a.InnerStepId != null)
|
||||||
|
.ToListAsync();
|
||||||
|
rows.Should().HaveCount(3);
|
||||||
|
rows.Count(r => r.IsBypassed).Should().Be(2);
|
||||||
|
rows.Count(r => !r.IsBypassed).Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NStage_Wrong_Department_Throws_Forbidden()
|
||||||
|
{
|
||||||
|
var defId = await SeedWorkflowDefinitionAsync(
|
||||||
|
(_deptPro, PositionLevel.NhanVien));
|
||||||
|
var contract = await SeedContractAsync(ContractPhase.DangGopY, defId);
|
||||||
|
|
||||||
|
var ccmActor = await _fx.CreateUserAsync(
|
||||||
|
$"ccm-wrong-{Guid.NewGuid():N}@test", "CCM wrong",
|
||||||
|
_deptCcm, ["Procurement"], positionLevel: PositionLevel.NhanVien);
|
||||||
|
|
||||||
|
var act = async () => await _service.TransitionAsync(
|
||||||
|
contract, ContractPhase.DangDamPhan, ccmActor.Id, ["Procurement"],
|
||||||
|
ApprovalDecision.Approve, "wrong dept");
|
||||||
|
await act.Should().ThrowAsync<ForbiddenException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NStage_Reject_Clears_InnerStep_Rows_At_Phase()
|
||||||
|
{
|
||||||
|
var defId = await SeedWorkflowDefinitionAsync(
|
||||||
|
(_deptPro, PositionLevel.NhanVien),
|
||||||
|
(_deptPro, PositionLevel.PhoPhong));
|
||||||
|
var contract = await SeedContractAsync(ContractPhase.DangGopY, defId);
|
||||||
|
|
||||||
|
var nv = await _fx.CreateUserAsync($"nv-rej-{Guid.NewGuid():N}@test", "NV", _deptPro, ["Procurement"], positionLevel: PositionLevel.NhanVien);
|
||||||
|
await _service.TransitionAsync(contract, ContractPhase.DangDamPhan, nv.Id, ["Procurement"], ApprovalDecision.Approve, "NV");
|
||||||
|
|
||||||
|
var rowsBefore = await _db.ContractDepartmentApprovals.AsNoTracking()
|
||||||
|
.Where(a => a.ContractId == contract.Id && a.InnerStepId != null).CountAsync();
|
||||||
|
rowsBefore.Should().Be(1);
|
||||||
|
|
||||||
|
contract = await _db.Contracts.FirstAsync(x => x.Id == contract.Id);
|
||||||
|
|
||||||
|
// Admin reject (skip dept block guard).
|
||||||
|
var admin = await _fx.CreateUserAsync($"adm-rej-{Guid.NewGuid():N}@test", "Admin", null, ["Admin"]);
|
||||||
|
await _service.TransitionAsync(
|
||||||
|
contract, ContractPhase.TuChoi, admin.Id, ["Admin"],
|
||||||
|
ApprovalDecision.Reject, "reject test");
|
||||||
|
|
||||||
|
var fresh = await _db.Contracts.AsNoTracking().FirstAsync(x => x.Id == contract.Id);
|
||||||
|
fresh.Phase.Should().Be(ContractPhase.DangSoanThao);
|
||||||
|
fresh.RejectedFromPhase.Should().Be(ContractPhase.DangGopY);
|
||||||
|
|
||||||
|
var rowsAfter = await _db.ContractDepartmentApprovals.AsNoTracking()
|
||||||
|
.Where(a => a.ContractId == contract.Id && a.InnerStepId != null).CountAsync();
|
||||||
|
rowsAfter.Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LegacyFallback_NoInnerSteps_Uses_2Stage_Logic()
|
||||||
|
{
|
||||||
|
// Không pin WorkflowDefinitionId → service fallback hardcoded Standard
|
||||||
|
// policy → no inner steps → legacy 2-stage logic kick in.
|
||||||
|
// Phase pair DangKiemTraCCM → DangTrinhKy yêu cầu role CostControl
|
||||||
|
// (Standard.Transitions). NV.CCM (role CostControl, KHÔNG DeptManager)
|
||||||
|
// → Stage=Review block.
|
||||||
|
var contract = await SeedContractAsync(ContractPhase.DangKiemTraCCM, workflowDefinitionId: null);
|
||||||
|
|
||||||
|
var nv = await _fx.CreateUserAsync(
|
||||||
|
$"nv-legacy-ctr-{Guid.NewGuid():N}@test", "NV legacy CTR",
|
||||||
|
_deptCcm, ["CostControl"]);
|
||||||
|
|
||||||
|
await _service.TransitionAsync(
|
||||||
|
contract, ContractPhase.DangTrinhKy, nv.Id, ["CostControl"],
|
||||||
|
ApprovalDecision.Approve, "legacy review");
|
||||||
|
|
||||||
|
var fresh = await _db.Contracts.AsNoTracking().FirstAsync(x => x.Id == contract.Id);
|
||||||
|
fresh.Phase.Should().Be(ContractPhase.DangKiemTraCCM);
|
||||||
|
|
||||||
|
var rows = await _db.ContractDepartmentApprovals.AsNoTracking()
|
||||||
|
.Where(a => a.ContractId == contract.Id).ToListAsync();
|
||||||
|
rows.Should().HaveCount(1);
|
||||||
|
rows[0].InnerStepId.Should().BeNull();
|
||||||
|
rows[0].Stage.Should().Be(ApprovalStage.Review);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub services — Contract workflow tests không cần verify changelog/codegen
|
||||||
|
// (best effort try/catch ở service đã cover fail case).
|
||||||
|
internal class FakeChangelogService : IChangelogService
|
||||||
|
{
|
||||||
|
public Task LogContractChangeAsync(Guid contractId, ChangelogAction action,
|
||||||
|
string? summary = null, string? fieldChangesJson = null, string? contextNote = null,
|
||||||
|
ContractPhase? phaseAtChange = null, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task LogDetailChangeAsync(Guid contractId, Guid detailId, ChangelogAction action,
|
||||||
|
string? summary = null, string? fieldChangesJson = null,
|
||||||
|
ContractPhase? phaseAtChange = null, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task LogWorkflowTransitionAsync(Guid contractId, ContractPhase fromPhase,
|
||||||
|
ContractPhase toPhase, string? comment, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task LogCommentAddedAsync(Guid contractId, string content, ContractPhase phase,
|
||||||
|
CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task LogAttachmentAsync(Guid contractId, Guid attachmentId, ChangelogAction action,
|
||||||
|
string fileName, ContractPhase phase, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class FakeContractCodeGenerator : IContractCodeGenerator
|
||||||
|
{
|
||||||
|
public Task<string> GenerateAsync(Contract contract, string projectCode, string supplierCode,
|
||||||
|
CancellationToken ct = default) => Task.FromResult($"FAKE-{projectCode}-{supplierCode}-001");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user