diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/CreateContractCommandApplicableTypeTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/CreateContractCommandApplicableTypeTests.cs new file mode 100644 index 0000000..7383625 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/CreateContractCommandApplicableTypeTests.cs @@ -0,0 +1,115 @@ +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; +using SolutionErp.Domain.ApprovalWorkflowsV2; +using SolutionErp.Domain.Common; +using SolutionErp.Domain.Contracts; +using SolutionErp.Domain.Identity; +using SolutionErp.Domain.Master; +using SolutionErp.Infrastructure.Services; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Application; + +// Plan C B-Wrap BW5 (S33) — CreateContractCommand validate ApprovalWorkflowId V2. +// Defense-in-depth guard: FE Workspace dropdown server-side filter ApplicableType=Contract(3), +// nhưng BE guard chặn attacker forge POST với PE workflow ID (ApplicableType=DuyetNcc=1 +// hoặc DuyetNccPhuongAn=2). +// +// Code path: ContractFeatures.cs line 78-86 — throw ConflictException (NOT +// ValidationException như spec mention; FluentValidation chỉ rule MaximumLength +// + GreaterThanOrEqualTo, KHÔNG có rule cross-table check ApplicableType). +public class CreateContractCommandApplicableTypeTests +{ + [Fact] + public async Task Create_PinApprovalWorkflowId_ApplicableType_DuyetNcc_Throws() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + var drafter = await fix.CreateUserAsync("drafter-bw5@test.local", "Drafter BW5", + departmentId: null, roles: new[] { AppRoles.Drafter }); + + // Seed Supplier + Project (handler validate existence) + var sup = new Supplier { Id = Guid.NewGuid(), Code = "ABC", Name = "NCC ABC", Type = SupplierType.NhaThauPhu }; + var proj = new Project { Id = Guid.NewGuid(), Code = "PROJ01", Name = "Dự án 01" }; + db.Suppliers.Add(sup); + db.Projects.Add(proj); + + // Seed PE-only workflow (ApplicableType=DuyetNcc) — attacker payload + var peOnlyWf = new ApprovalWorkflow + { + Id = Guid.NewGuid(), + Code = "QT-PE-ONLY", + Version = 1, + Name = "PE-only workflow", + ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc, + IsActive = true, + IsUserSelectable = true, + }; + db.ApprovalWorkflows.Add(peOnlyWf); + await db.SaveChangesAsync(CancellationToken.None); + + // Wire handler 5 deps mirror prod (ContractFeatures.cs:53-58) + var um = fix.Services.GetRequiredService>(); + var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc)); + var notify = new NoOpNotificationServiceApp(); + var currentUser = new TestCurrentUser { UserId = drafter.Id, Roles = new[] { AppRoles.Drafter } }; + var changelog = new ChangelogService(db, currentUser, um); + var codeGen = new ContractCodeGenerator(db, dt); + var workflowSvc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um); + var handler = new CreateContractCommandHandler(db, currentUser, workflowSvc, codeGen, changelog); + + var cmd = new CreateContractCommand( + Type: ContractType.HopDongThauPhu, + SupplierId: sup.Id, + ProjectId: proj.Id, + DepartmentId: null, + TemplateId: null, + GiaTri: 100_000_000m, + TenHopDong: "Forge attempt — PE workflow", + NoiDung: null, + BypassProcurementAndCCM: false, + DraftData: null, + BudgetId: null, + BudgetManualName: null, + BudgetManualAmount: null, + ApprovalWorkflowId: peOnlyWf.Id); + + var act = async () => await handler.Handle(cmd, CancellationToken.None); + + // Note: spec BW5 mention ValidationException — thực tế ContractFeatures.cs + // line 84 throw ConflictException ("Quy trình {Code} áp dụng cho {ApplicableType}, + // không khớp với HĐ (cần ApplicableType=Contract)."). FluentValidation + // (line 38-50) chỉ rule field-level (MaximumLength, GreaterThanOrEqualTo), + // KHÔNG có rule cross-table. + await act.Should().ThrowAsync() + .WithMessage("*ApplicableType=Contract*"); + } +} + +// NoOp ICurrentUser-NOT-NEEDED notif — ContractWorkflowService DI dùng INotificationService +// (gửi noti drafter khi terminal). Bare-bone stub. +internal sealed class NoOpNotificationServiceApp : SolutionErp.Application.Notifications.INotificationService +{ + public Task NotifyAsync( + Guid userId, + SolutionErp.Domain.Notifications.NotificationType type, + string title, + string? description = null, + string? href = null, + Guid? refId = null, + CancellationToken ct = default) => Task.CompletedTask; + + public Task NotifyManyAsync( + IEnumerable userIds, + SolutionErp.Domain.Notifications.NotificationType type, + string title, + string? description = null, + string? href = null, + Guid? refId = null, + CancellationToken ct = default) => Task.CompletedTask; +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Common/ContractV2SchemaPersistenceTests.cs b/tests/SolutionErp.Infrastructure.Tests/Common/ContractV2SchemaPersistenceTests.cs new file mode 100644 index 0000000..88e093d --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Common/ContractV2SchemaPersistenceTests.cs @@ -0,0 +1,238 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Domain.ApprovalWorkflowsV2; +using SolutionErp.Domain.Common; +using SolutionErp.Domain.Contracts; +using SolutionErp.Domain.Identity; +using SolutionErp.Domain.Master; + +namespace SolutionErp.Infrastructure.Tests.Common; + +// Plan C B-Wrap BW6 (S33) — ContractLevelOpinion schema persistence verification +// (Mig 33 — cookie-cutter mirror PE Mig 26). +// +// 3 assertion: +// 1. UNIQUE composite (ContractId, ApprovalWorkflowLevelId) — không cho 2 row +// cùng (HĐ, Level slot) → DbUpdateException khi cố insert duplicate. +// 2. UPSERT pattern fetch + update — Comment thay đổi, vẫn 1 row only. +// 3. FK Cascade Contract — xoá HĐ → ContractLevelOpinions auto-deleted. +// +// FK Restrict ApprovalWorkflowLevel KHÔNG test (per spec): +// - Admin xoá Level chặn nếu opinion tồn tại (data preservation guarantee) +// - Verify ngoài qua manual SQL hoặc integration test riêng. +public class ContractV2SchemaPersistenceTests +{ + [Fact] + public async Task ContractLevelOpinion_DuplicateComposite_ThrowsDbUpdateException() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + var approver = await fix.CreateUserAsync("a-bw6@test.local", "Approver BW6", + departmentId: null, roles: new[] { AppRoles.CostControl }); + + // Seed 1 Workflow + 1 Step + 1 Level + var wf = new ApprovalWorkflow + { + Id = Guid.NewGuid(), + Code = "QT-BW6-001", + Version = 1, + Name = "BW6 unique test", + ApplicableType = ApprovalWorkflowApplicableType.Contract, + 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 = approver.Id, + }; + db.ApprovalWorkflows.Add(wf); + db.ApprovalWorkflowSteps.Add(step); + db.ApprovalWorkflowLevels.Add(level); + + // Seed Supplier + Project + Contract V2 + var sup = new Supplier { Id = Guid.NewGuid(), Code = "ABC", Name = "NCC ABC", Type = SupplierType.NhaThauPhu }; + var proj = new Project { Id = Guid.NewGuid(), Code = "PROJ01", Name = "Dự án 01" }; + db.Suppliers.Add(sup); + db.Projects.Add(proj); + + var contract = new Contract + { + Id = Guid.NewGuid(), + Type = ContractType.HopDongThauPhu, + Phase = ContractPhase.ChoDuyet, + SupplierId = sup.Id, + ProjectId = proj.Id, + DrafterUserId = Guid.NewGuid(), + TenHopDong = "Test BW6 unique", + GiaTri = 10_000_000m, + ApprovalWorkflowId = wf.Id, + CurrentWorkflowStepIndex = 0, + CurrentApprovalLevelOrder = 1, + }; + db.Contracts.Add(contract); + await db.SaveChangesAsync(CancellationToken.None); + + // 2 ContractLevelOpinion cùng (ContractId, ApprovalWorkflowLevelId) + db.ContractLevelOpinions.Add(new ContractLevelOpinion + { + ContractId = contract.Id, + ApprovalWorkflowLevelId = level.Id, + Comment = "Ý kiến lần 1", + SignedAt = DateTime.UtcNow, + SignedByUserId = approver.Id, + SignedByFullName = "Approver BW6", + }); + db.ContractLevelOpinions.Add(new ContractLevelOpinion + { + ContractId = contract.Id, + ApprovalWorkflowLevelId = level.Id, + Comment = "Ý kiến lần 2 — duplicate", + SignedAt = DateTime.UtcNow, + SignedByUserId = approver.Id, + SignedByFullName = "Approver BW6", + }); + + var act = async () => await db.SaveChangesAsync(CancellationToken.None); + await act.Should().ThrowAsync( + "UNIQUE composite (ContractId, ApprovalWorkflowLevelId) — Mig 33"); + } + + [Fact] + public async Task ContractLevelOpinion_UpsertPattern_FetchAndUpdate_KeepsSingleRow() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + var approver = await fix.CreateUserAsync("a-bw6-up@test.local", "Approver BW6 UP", + departmentId: null, roles: new[] { AppRoles.CostControl }); + + var (wf, step, level, sup, proj, contract) = await SeedBaseAsync(db, approver.Id); + + // Initial insert + db.ContractLevelOpinions.Add(new ContractLevelOpinion + { + ContractId = contract.Id, + ApprovalWorkflowLevelId = level.Id, + Comment = "Ý kiến đầu tiên", + SignedAt = DateTime.UtcNow, + SignedByUserId = approver.Id, + SignedByFullName = "Approver BW6 UP", + }); + await db.SaveChangesAsync(CancellationToken.None); + + // UPSERT — fetch + update + var existing = await db.ContractLevelOpinions + .FirstAsync(o => o.ContractId == contract.Id && o.ApprovalWorkflowLevelId == level.Id); + existing.Comment = "Ý kiến đã được cập nhật"; + existing.SignedAt = DateTime.UtcNow.AddSeconds(1); + await db.SaveChangesAsync(CancellationToken.None); + + var rows = await db.ContractLevelOpinions + .Where(o => o.ContractId == contract.Id).ToListAsync(); + rows.Should().HaveCount(1, "UPSERT giữ duy nhất 1 row per (HĐ, Level)"); + rows[0].Comment.Should().Be("Ý kiến đã được cập nhật"); + } + + [Fact] + public async Task ContractLevelOpinion_FkCascade_DeleteContract_AlsoDeletesOpinions() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + var approver = await fix.CreateUserAsync("a-bw6-cas@test.local", "Approver BW6 CAS", + departmentId: null, roles: new[] { AppRoles.CostControl }); + + var (wf, step, level, sup, proj, contract) = await SeedBaseAsync(db, approver.Id); + + db.ContractLevelOpinions.Add(new ContractLevelOpinion + { + ContractId = contract.Id, + ApprovalWorkflowLevelId = level.Id, + Comment = "Sẽ bị wipe khi xoá HĐ", + SignedAt = DateTime.UtcNow, + SignedByUserId = approver.Id, + SignedByFullName = "Approver BW6 CAS", + }); + await db.SaveChangesAsync(CancellationToken.None); + + var beforeCount = await db.ContractLevelOpinions + .CountAsync(o => o.ContractId == contract.Id); + beforeCount.Should().Be(1); + + // Hard delete Contract — Cascade wipe opinions + db.Contracts.Remove(contract); + await db.SaveChangesAsync(CancellationToken.None); + + var afterCount = await db.ContractLevelOpinions + .CountAsync(o => o.ContractId == contract.Id); + afterCount.Should().Be(0, "FK Cascade Mig 33 — xoá HĐ wipe ContractLevelOpinions"); + } + + // Helper seed full chain: Workflow + Step + Level + Supplier + Project + Contract. + private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep step, + ApprovalWorkflowLevel level, Supplier sup, Project proj, Contract contract)> + SeedBaseAsync(TestApplicationDbContext db, Guid approverUserId) + { + var wf = new ApprovalWorkflow + { + Id = Guid.NewGuid(), + Code = $"QT-BW6-{Guid.NewGuid().ToString()[..6]}", + Version = 1, + Name = "BW6 helper", + ApplicableType = ApprovalWorkflowApplicableType.Contract, + 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, + }; + var sup = new Supplier { Id = Guid.NewGuid(), Code = "ABC", Name = "NCC ABC", Type = SupplierType.NhaThauPhu }; + var proj = new Project { Id = Guid.NewGuid(), Code = "PROJ01", Name = "Dự án 01" }; + var contract = new Contract + { + Id = Guid.NewGuid(), + Type = ContractType.HopDongThauPhu, + Phase = ContractPhase.ChoDuyet, + SupplierId = sup.Id, + ProjectId = proj.Id, + DrafterUserId = Guid.NewGuid(), + TenHopDong = "BW6 helper contract", + GiaTri = 10_000_000m, + ApprovalWorkflowId = wf.Id, + CurrentWorkflowStepIndex = 0, + CurrentApprovalLevelOrder = 1, + }; + db.ApprovalWorkflows.Add(wf); + db.ApprovalWorkflowSteps.Add(step); + db.ApprovalWorkflowLevels.Add(level); + db.Suppliers.Add(sup); + db.Projects.Add(proj); + db.Contracts.Add(contract); + await db.SaveChangesAsync(CancellationToken.None); + return (wf, step, level, sup, proj, contract); + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Common/TestCurrentUser.cs b/tests/SolutionErp.Infrastructure.Tests/Common/TestCurrentUser.cs new file mode 100644 index 0000000..3328eee --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Common/TestCurrentUser.cs @@ -0,0 +1,32 @@ +using SolutionErp.Application.Common.Interfaces; + +namespace SolutionErp.Infrastructure.Tests.Common; + +// Stub ICurrentUser cho tests cần inject ChangelogService (Contract V2 wire S29). +// ChangelogService resolve actor qua ICurrentUser.UserId → cần stub +// configurable per test scenario (vd BW3 admin bypass cần Roles chứa "Admin"). +// +// Pattern: instance per test, set Acting* properties trước khi gọi service. +// Khác ICurrentUser prod (HttpContextCurrentUser) đọc JWT claims — test +// override trực tiếp. +public sealed class TestCurrentUser : ICurrentUser +{ + public Guid? UserId { get; set; } + public string? Email { get; set; } + public string? FullName { get; set; } + public IReadOnlyList Roles { get; set; } = Array.Empty(); + public bool IsAuthenticated => UserId is not null; + + public TestCurrentUser() { } + + public TestCurrentUser(Guid userId, string? fullName = null, string? email = null, params string[] roles) + { + UserId = userId; + FullName = fullName; + Email = email; + Roles = roles ?? Array.Empty(); + } + + // Helper: simulate system actor (vd SLA auto-approve, DbInitializer seed). + public static TestCurrentUser System() => new(); +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/ContractWorkflowServiceApproveV2Tests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/ContractWorkflowServiceApproveV2Tests.cs new file mode 100644 index 0000000..f602917 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Services/ContractWorkflowServiceApproveV2Tests.cs @@ -0,0 +1,518 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Domain.ApprovalWorkflowsV2; +using SolutionErp.Domain.Common; +using SolutionErp.Domain.Contracts; +using SolutionErp.Domain.Identity; +using SolutionErp.Domain.Master; +using SolutionErp.Infrastructure.Services; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Services; + +// Plan C B-Wrap (S33) — Contract V2 ApproveV2Async cookie-cutter mirror PE +// PurchaseEvaluationWorkflowServiceReturnModeTests (S23 t5). 5 [Fact] cover +// happy/terminal/skip/outsider-guard/V1-fallback. +// +// Service wire 6 deps (mirror prod): +// ContractWorkflowService(db, ContractCodeGenerator, FixedDateTime, +// NoOpNotificationService, ChangelogService(TestCurrentUser, um), +// UserManager) +// Lý do dùng ChangelogService thật + TestCurrentUser stub: BW1+BW3 cần assert +// ContractChangelog row được log (summary + ContextNote). Mock service phá assertion này. +public class ContractWorkflowServiceApproveV2Tests +{ + private static (ContractWorkflowService svc, IdentityFixture fix, TestApplicationDbContext db, FixedDateTime dt, TestCurrentUser currentUser) + CreateService(Guid? actorUserId = null, params string[] actorRoles) + { + var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc)); + var notify = new NoOpNotificationService(); + var currentUser = new TestCurrentUser + { + UserId = actorUserId, + Roles = actorRoles ?? Array.Empty(), + }; + var changelog = new ChangelogService(db, currentUser, um); + var codeGen = new ContractCodeGenerator(db, dt); + var svc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um); + return (svc, fix, db, dt, currentUser); + } + + // Workflow setup: 1 Bước (1 Step) — 2 Cấp (2 Levels), mỗi Cấp 1 NV. + // Default Allow* = false trên Level (admin opt-in pattern Mig 29). + private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep step, ApprovalWorkflowLevel l1, ApprovalWorkflowLevel l2)> + SeedWorkflowAsync( + TestApplicationDbContext db, + Guid approver1UserId, + Guid approver2UserId, + string code = "QT-CT-001") + { + var wf = new ApprovalWorkflow + { + Id = Guid.NewGuid(), + Code = code, + Version = 1, + Name = "Test Contract Workflow V2", + ApplicableType = ApprovalWorkflowApplicableType.Contract, + IsActive = true, + IsUserSelectable = true, + }; + var step = new ApprovalWorkflowStep + { + Id = Guid.NewGuid(), + ApprovalWorkflowId = wf.Id, + Order = 1, + DepartmentId = null, + Name = "Bước 1 Phòng Kỹ Thuật", + }; + var l1 = new ApprovalWorkflowLevel + { + Id = Guid.NewGuid(), + ApprovalWorkflowStepId = step.Id, + Order = 1, + ApproverUserId = approver1UserId, + }; + var l2 = new ApprovalWorkflowLevel + { + Id = Guid.NewGuid(), + ApprovalWorkflowStepId = step.Id, + Order = 2, + ApproverUserId = approver2UserId, + }; + db.ApprovalWorkflows.Add(wf); + db.ApprovalWorkflowSteps.Add(step); + db.ApprovalWorkflowLevels.Add(l1); + db.ApprovalWorkflowLevels.Add(l2); + await db.SaveChangesAsync(CancellationToken.None); + return (wf, step, l1, l2); + } + + // BW2: 1 Step × 1 Level — terminal sau Approve duy nhất. + private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep step, ApprovalWorkflowLevel l1)> + SeedSingleLevelWorkflowAsync(TestApplicationDbContext db, Guid approverUserId) + { + var wf = new ApprovalWorkflow + { + Id = Guid.NewGuid(), + Code = "QT-CT-TERMINAL", + Version = 1, + Name = "Test Single Level", + ApplicableType = ApprovalWorkflowApplicableType.Contract, + IsActive = true, + IsUserSelectable = true, + }; + var step = new ApprovalWorkflowStep + { + Id = Guid.NewGuid(), + ApprovalWorkflowId = wf.Id, + Order = 1, + DepartmentId = null, + Name = "Bước duy nhất", + }; + var l1 = new ApprovalWorkflowLevel + { + Id = Guid.NewGuid(), + ApprovalWorkflowStepId = step.Id, + Order = 1, + ApproverUserId = approverUserId, + }; + db.ApprovalWorkflows.Add(wf); + db.ApprovalWorkflowSteps.Add(step); + db.ApprovalWorkflowLevels.Add(l1); + await db.SaveChangesAsync(CancellationToken.None); + return (wf, step, l1); + } + + // BW3: 3 Step × 2 Level mỗi Step + slot Cấp 1 Bước 1 set AllowApproverSkipToFinal. + private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep s1, ApprovalWorkflowLevel s1l1, ApprovalWorkflowLevel s1l2, ApprovalWorkflowStep s2, ApprovalWorkflowStep s3)> + SeedMultiStepF2WorkflowAsync( + TestApplicationDbContext db, + Guid s1l1Approver, + Guid s1l2Approver, + Guid s2l1Approver, + Guid s2l2Approver, + Guid s3l1Approver, + Guid s3l2Approver, + bool allowSkipToFinalSlotS1L1 = true) + { + var wf = new ApprovalWorkflow + { + Id = Guid.NewGuid(), + Code = "QT-CT-F2", + Version = 1, + Name = "Test 3 Step F2 Contract", + ApplicableType = ApprovalWorkflowApplicableType.Contract, + IsActive = true, + IsUserSelectable = true, + }; + var s1 = new ApprovalWorkflowStep { Id = Guid.NewGuid(), ApprovalWorkflowId = wf.Id, Order = 1, DepartmentId = null, Name = "Bước 1 Kỹ Thuật" }; + var s2 = new ApprovalWorkflowStep { Id = Guid.NewGuid(), ApprovalWorkflowId = wf.Id, Order = 2, DepartmentId = null, Name = "Bước 2 CCM" }; + var s3 = new ApprovalWorkflowStep { Id = Guid.NewGuid(), ApprovalWorkflowId = wf.Id, Order = 3, DepartmentId = null, Name = "Bước 3 GĐ" }; + var s1l1 = new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = s1.Id, Order = 1, ApproverUserId = s1l1Approver, AllowApproverSkipToFinal = allowSkipToFinalSlotS1L1 }; + var s1l2 = new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = s1.Id, Order = 2, ApproverUserId = s1l2Approver }; + var s2l1 = new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = s2.Id, Order = 1, ApproverUserId = s2l1Approver }; + var s2l2 = new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = s2.Id, Order = 2, ApproverUserId = s2l2Approver }; + var s3l1 = new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = s3.Id, Order = 1, ApproverUserId = s3l1Approver }; + var s3l2 = new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = s3.Id, Order = 2, ApproverUserId = s3l2Approver }; + db.ApprovalWorkflows.Add(wf); + db.ApprovalWorkflowSteps.AddRange(s1, s2, s3); + db.ApprovalWorkflowLevels.AddRange(s1l1, s1l2, s2l1, s2l2, s3l1, s3l2); + await db.SaveChangesAsync(CancellationToken.None); + return (wf, s1, s1l1, s1l2, s2, s3); + } + + // Build Contract V2 wiring helper. ContractDepartmentApprovals nav collection + // mặc định empty — KHÔNG cần seed cho V2 happy path (PE workflow legacy V1 mới dùng). + private static Contract BuildContractAtStep0Level( + Guid workflowId, + Guid supplierId, + Guid projectId, + Guid drafterId, + int levelOrder = 1, + ContractType type = ContractType.HopDongThauPhu) + { + return new Contract + { + Id = Guid.NewGuid(), + Type = type, + Phase = ContractPhase.ChoDuyet, + SupplierId = supplierId, + ProjectId = projectId, + DrafterUserId = drafterId, + TenHopDong = "Test V2 contract", + GiaTri = 100_000_000m, + ApprovalWorkflowId = workflowId, + CurrentWorkflowStepIndex = 0, + CurrentApprovalLevelOrder = levelOrder, + }; + } + + // Seed Supplier + Project bằng Code cố định để gen mã RG-001 predictable. + private static async Task<(Supplier sup, Project proj)> SeedSupplierProjectAsync( + TestApplicationDbContext db, + string supplierCode = "BTBM", + string projectCode = "FLOCK01") + { + var sup = new Supplier + { + Id = Guid.NewGuid(), + Code = supplierCode, + Name = $"NCC {supplierCode}", + Type = SupplierType.NhaThauPhu, + }; + var proj = new Project + { + Id = Guid.NewGuid(), + Code = projectCode, + Name = $"Dự án {projectCode}", + }; + db.Suppliers.Add(sup); + db.Projects.Add(proj); + await db.SaveChangesAsync(CancellationToken.None); + return (sup, proj); + } + + // ============ BW1: Happy path step advance Cấp 1 → Cấp 2 cùng Bước ============ + + [Fact] + public async Task ApproveV2_FirstLevel_AdvancesToSecondLevel_SameStep() + { + var fix = new IdentityFixture(); + using (fix) + { + var db = fix.Services.GetRequiredService(); + var approver1 = await fix.CreateUserAsync("a1-bw1@test.local", "Approver 1 BW1", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var approver2 = await fix.CreateUserAsync("a2-bw1@test.local", "Approver 2 BW1", + departmentId: null, roles: new[] { AppRoles.CostControl }); + + // Recreate service với actorUserId = approver1 cho ChangelogService resolve UserName đúng + var um = fix.Services.GetRequiredService>(); + var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc)); + var notify = new NoOpNotificationService(); + var currentUser = new TestCurrentUser { UserId = approver1.Id, Roles = new[] { AppRoles.CostControl } }; + var changelog = new ChangelogService(db, currentUser, um); + var codeGen = new ContractCodeGenerator(db, dt); + var svc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um); + + var (wf, _, l1, _) = await SeedWorkflowAsync(db, approver1.Id, approver2.Id); + var (sup, proj) = await SeedSupplierProjectAsync(db); + var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id, drafterId: Guid.NewGuid(), levelOrder: 1); + db.Contracts.Add(contract); + await db.SaveChangesAsync(CancellationToken.None); + + await svc.TransitionAsync( + contract: contract, + targetPhase: ContractPhase.ChoDuyet, + actorUserId: approver1.Id, + actorRoles: new[] { AppRoles.CostControl }, + decision: ApprovalDecision.Approve, + comment: "trải nghiệm test", + ct: CancellationToken.None); + + contract.CurrentApprovalLevelOrder.Should().Be(2, "Lên Cấp 2 cùng Bước 1"); + contract.CurrentWorkflowStepIndex.Should().Be(0, "Step không advance vì còn Cấp 2"); + contract.Phase.Should().Be(ContractPhase.ChoDuyet, "Phase giữ ChoDuyet (chưa terminal)"); + contract.SlaDeadline.Should().NotBeNull("SLA reset 7d cho Cấp 2 nhận phiếu"); + + var approvals = await db.ContractApprovals + .Where(a => a.ContractId == contract.Id).ToListAsync(); + approvals.Should().HaveCount(1); + approvals[0].ApproverUserId.Should().Be(approver1.Id); + approvals[0].Decision.Should().Be(ApprovalDecision.Approve); + + var opinions = await db.ContractLevelOpinions + .Where(o => o.ContractId == contract.Id).ToListAsync(); + opinions.Should().HaveCount(1, "UPSERT 1 row cho slot Cấp 1"); + opinions[0].ApprovalWorkflowLevelId.Should().Be(l1.Id); + opinions[0].Comment.Should().Be("trải nghiệm test"); + opinions[0].SignedByUserId.Should().Be(approver1.Id); + + var changelogs = await db.ContractChangelogs + .Where(c => c.ContractId == contract.Id + && c.EntityType == ChangelogEntityType.Workflow).ToListAsync(); + changelogs.Should().Contain(c => c.ContextNote != null + && c.ContextNote.Contains("Hoàn tất Cấp 1, sang Cấp 2 cùng Bước 1")); + } + } + + // ============ BW2: Terminal Cấp cuối Bước cuối → DaPhatHanh + gen mã HĐ ============ + + [Fact] + public async Task ApproveV2_LastLevel_FinalStep_TransitionsToDaPhatHanh_GeneratesMaHopDong() + { + var fix = new IdentityFixture(); + using (fix) + { + var db = fix.Services.GetRequiredService(); + var approver = await fix.CreateUserAsync("a-bw2@test.local", "Approver BW2", + departmentId: null, roles: new[] { AppRoles.CostControl }); + + var um = fix.Services.GetRequiredService>(); + var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc)); + var notify = new NoOpNotificationService(); + var currentUser = new TestCurrentUser { UserId = approver.Id, Roles = new[] { AppRoles.CostControl } }; + var changelog = new ChangelogService(db, currentUser, um); + var codeGen = new ContractCodeGenerator(db, dt); + var svc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um); + + var (wf, _, l1) = await SeedSingleLevelWorkflowAsync(db, approver.Id); + var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "BTBM", projectCode: "FLOCK01"); + var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id, + drafterId: Guid.NewGuid(), levelOrder: 1, type: ContractType.HopDongThauPhu); + db.Contracts.Add(contract); + await db.SaveChangesAsync(CancellationToken.None); + + await svc.TransitionAsync( + contract: contract, + targetPhase: ContractPhase.ChoDuyet, + actorUserId: approver.Id, + actorRoles: new[] { AppRoles.CostControl }, + decision: ApprovalDecision.Approve, + comment: "duyệt cuối", + ct: CancellationToken.None); + + contract.Phase.Should().Be(ContractPhase.DaPhatHanh, "Terminal sau Cấp cuối Bước cuối"); + contract.MaHopDong.Should().NotBeNull(); + contract.MaHopDong.Should().Be("FLOCK01/HĐTP/SOL&BTBM/01", + "RG-001 format ContractType.HopDongThauPhu"); + contract.CurrentWorkflowStepIndex.Should().BeNull(); + contract.CurrentApprovalLevelOrder.Should().BeNull(); + contract.SlaDeadline.Should().BeNull(); + + var approvals = await db.ContractApprovals + .Where(a => a.ContractId == contract.Id).ToListAsync(); + approvals.Should().HaveCount(1); + + var opinions = await db.ContractLevelOpinions + .Where(o => o.ContractId == contract.Id).ToListAsync(); + opinions.Should().HaveCount(1, "Final UPSERT slot Cấp 1"); + opinions[0].ApprovalWorkflowLevelId.Should().Be(l1.Id); + + var changelogs = await db.ContractChangelogs + .Where(c => c.ContractId == contract.Id + && c.EntityType == ChangelogEntityType.Workflow).ToListAsync(); + changelogs.Should().Contain(c => c.Summary != null + && c.Summary.Contains("ChoDuyet") && c.Summary.Contains("DaPhatHanh")); + } + } + + // ============ BW3: skipToFinal F2 admin opt-in ============ + + [Fact] + public async Task ApproveV2_SkipToFinal_AdminTickFlag_AdvancesToLastStepLastLevel() + { + var fix = new IdentityFixture(); + using (fix) + { + var db = fix.Services.GetRequiredService(); + var userA = await fix.CreateUserAsync("usera-bw3@test.local", "User A BW3", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var userB = await fix.CreateUserAsync("userb-bw3@test.local", "User B BW3", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var userC = await fix.CreateUserAsync("userc-bw3@test.local", "User C BW3", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var userD = await fix.CreateUserAsync("userd-bw3@test.local", "User D BW3", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var userE = await fix.CreateUserAsync("usere-bw3@test.local", "User E BW3", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var userF = await fix.CreateUserAsync("userf-bw3@test.local", "User F BW3", + departmentId: null, roles: new[] { AppRoles.CostControl }); + + var um = fix.Services.GetRequiredService>(); + var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc)); + var notify = new NoOpNotificationService(); + var currentUser = new TestCurrentUser { UserId = userA.Id, Roles = new[] { AppRoles.CostControl } }; + var changelog = new ChangelogService(db, currentUser, um); + var codeGen = new ContractCodeGenerator(db, dt); + var svc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um); + + var (wf, _, _, _, _, _) = await SeedMultiStepF2WorkflowAsync( + db, userA.Id, userB.Id, userC.Id, userD.Id, userE.Id, userF.Id, + allowSkipToFinalSlotS1L1: true); + var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "BTBM", projectCode: "FLOCK01"); + var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id, + drafterId: Guid.NewGuid(), levelOrder: 1); + db.Contracts.Add(contract); + await db.SaveChangesAsync(CancellationToken.None); + + await svc.TransitionAsync( + contract: contract, + targetPhase: ContractPhase.ChoDuyet, + actorUserId: userA.Id, + actorRoles: new[] { AppRoles.CostControl }, + decision: ApprovalDecision.Approve, + comment: "duyệt thẳng cấp cuối", + skipToFinal: true, + ct: CancellationToken.None); + + contract.CurrentWorkflowStepIndex.Should().Be(2, + "lastStepIdx Bước 3 (index = 3-1 = 2)"); + contract.CurrentApprovalLevelOrder.Should().Be(2, + "lastLevelMaxOrder Cấp 2 Bước cuối"); + contract.Phase.Should().Be(ContractPhase.ChoDuyet, + "skip advance pointer KHÔNG terminal — NV cuối vẫn cần ký thật"); + contract.SlaDeadline.Should().NotBeNull(); + + var approvals = await db.ContractApprovals + .Where(a => a.ContractId == contract.Id).ToListAsync(); + approvals.Should().HaveCount(1); + approvals[0].Comment.Should().StartWith("[Duyệt vượt cấp tới Cấp cuối]", + "Prefix enrich từ ContractWorkflowService:270 khi skipToFinal=true"); + + var changelogs = await db.ContractChangelogs + .Where(c => c.ContractId == contract.Id + && c.EntityType == ChangelogEntityType.Workflow).ToListAsync(); + changelogs.Should().Contain(c => c.ContextNote != null + && c.ContextNote.Contains("Approver skip thẳng tới Bước 3 Cấp 2")); + } + } + + // ============ BW4: Outsider TransitionAsync(Approve) → ForbiddenException ============ + + [Fact] + public async Task ApproveV2_OutsiderNonAdmin_ThrowsForbiddenException() + { + var fix = new IdentityFixture(); + using (fix) + { + var db = fix.Services.GetRequiredService(); + var approver1 = await fix.CreateUserAsync("a1-bw4@test.local", "Approver 1 BW4", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var approver2 = await fix.CreateUserAsync("a2-bw4@test.local", "Approver 2 BW4", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var outsider = await fix.CreateUserAsync("out-bw4@test.local", "Outsider BW4", + departmentId: null, roles: new[] { AppRoles.CostControl }); + + var um = fix.Services.GetRequiredService>(); + var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc)); + var notify = new NoOpNotificationService(); + var currentUser = new TestCurrentUser { UserId = outsider.Id, Roles = new[] { AppRoles.CostControl } }; + var changelog = new ChangelogService(db, currentUser, um); + var codeGen = new ContractCodeGenerator(db, dt); + var svc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um); + + var (wf, _, _, _) = await SeedWorkflowAsync(db, approver1.Id, approver2.Id); + var (sup, proj) = await SeedSupplierProjectAsync(db); + var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id, + drafterId: Guid.NewGuid(), levelOrder: 1); + db.Contracts.Add(contract); + await db.SaveChangesAsync(CancellationToken.None); + + var act = async () => await svc.TransitionAsync( + contract: contract, + targetPhase: ContractPhase.ChoDuyet, + actorUserId: outsider.Id, + actorRoles: new[] { AppRoles.CostControl }, + decision: ApprovalDecision.Approve, + comment: "outsider thử approve", + ct: CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*Bước 1*Cấp 1: bạn không có trong danh sách NV duyệt*"); + contract.Phase.Should().Be(ContractPhase.ChoDuyet, + "Guard chặn trước mutate phase"); + contract.CurrentApprovalLevelOrder.Should().Be(1, "Pointer unchanged"); + } + } + + // ============ BW7: V1 fallback skipToFinal non-admin → ConflictException ============ + + [Fact] + public async Task ApproveV1Fallback_SkipToFinal_NonAdmin_ThrowsConflictException() + { + var fix = new IdentityFixture(); + using (fix) + { + var db = fix.Services.GetRequiredService(); + var approver = await fix.CreateUserAsync("a-bw7@test.local", "Approver BW7", + departmentId: null, roles: new[] { AppRoles.CostControl }); + + var um = fix.Services.GetRequiredService>(); + var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc)); + var notify = new NoOpNotificationService(); + var currentUser = new TestCurrentUser { UserId = approver.Id, Roles = new[] { AppRoles.CostControl } }; + var changelog = new ChangelogService(db, currentUser, um); + var codeGen = new ContractCodeGenerator(db, dt); + var svc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um); + + var (sup, proj) = await SeedSupplierProjectAsync(db); + // Contract V1 legacy — ApprovalWorkflowId = null + var contract = new Contract + { + Id = Guid.NewGuid(), + Type = ContractType.HopDongThauPhu, + Phase = ContractPhase.ChoDuyet, + SupplierId = sup.Id, + ProjectId = proj.Id, + DrafterUserId = Guid.NewGuid(), + TenHopDong = "V1 legacy contract", + GiaTri = 50_000_000m, + ApprovalWorkflowId = null, + WorkflowDefinitionId = null, + CurrentWorkflowStepIndex = 0, + }; + db.Contracts.Add(contract); + await db.SaveChangesAsync(CancellationToken.None); + + var act = async () => await svc.TransitionAsync( + contract: contract, + targetPhase: ContractPhase.ChoDuyet, + actorUserId: approver.Id, + actorRoles: new[] { AppRoles.CostControl }, + decision: ApprovalDecision.Approve, + comment: "thử skipToFinal V1", + skipToFinal: true, + ct: CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*skipToFinal chỉ hỗ trợ HĐ V2*"); + contract.Phase.Should().Be(ContractPhase.ChoDuyet, "State unchanged"); + } + } +}