diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/ContractNStageApprovalTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/ContractNStageApprovalTests.cs new file mode 100644 index 0000000..fa13dea --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Services/ContractNStageApprovalTests.cs @@ -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 +{ + private readonly IdentityFixture _fx; + private readonly TestApplicationDbContext _db; + private readonly UserManager _userManager; + private readonly ContractWorkflowService _service; + private readonly Guid _deptPro; + private readonly Guid _deptCcm; + + public ContractNStageApprovalTests(IdentityFixture fx) + { + _fx = fx; + _db = fx.Services.GetRequiredService(); + _userManager = fx.Services.GetRequiredService>(); + + _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 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 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(); + } + + [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 GenerateAsync(Contract contract, string projectCode, string supplierCode, + CancellationToken ct = default) => Task.FromResult($"FAKE-{projectCode}-{supplierCode}-001"); +}