[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

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:
pqhuy1987
2026-05-07 19:07:32 +07:00
parent e247b67681
commit 7c0772acca

View File

@ -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");
}