From 8353fe87c000eb530272abe7312d40f6ec4e67d3 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Mon, 4 May 2026 13:52:43 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Tests:=20Chunk=20E6=20=E2=80=94=206?= =?UTF-8?q?=20test=202-stage=20approval=20(PE)=20+=20IdentityFixture=20hel?= =?UTF-8?q?per?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Đóng "Tests Phase 3 mini cần UserManager DI helper" defer từ session 8. IdentityFixture (Common/IdentityFixture.cs): - Setup ServiceProvider với Identity stack đầy đủ: - DbContext SQLite shared connection - AddIdentityCore + AddRoles + AddEntityFrameworkStores - Single shared scope cho fixture lifetime → DbContext + UserManager đồng instance - Helper CreateUserAsync(email, name, deptId, roles, canBypassReview) - Note: dùng Role custom (không phải IdentityRole) để match ApplicationDbContext : IdentityDbContext 6 test PE 2-stage logic (Services/PeTwoStageApprovalTests.cs): - NV_Review_Blocks_Phase_Transition (đóng bug anh Kiệt — chính xác) - TPB_Confirm_After_NV_Review_Allows_Transition (happy path 2-stage) - NV_With_BypassReview_Allows_Transition_With_IsBypassed_True (bypass NV) - Admin_Skips_TwoStage_Logic_Entirely (admin bypass) - Reject_Sets_RejectedFromPhase_And_Forces_DangSoanThao (smart reject) - Resume_After_Reject_Jumps_Back_To_RejectedPhase (jump-back logic) Stub FakeNotificationService — best effort path không cần verify. Note: tests cho Contract + Budget 2-stage skip — logic identical PE, ROI thấp. Pattern PeTwoStageApprovalTests reusable nếu cần test riêng tương lai. Total: 54 Domain + 29 Infra (17 codegen + 6 PE WF Application + 6 PE 2-stage) = **83 test pass** (+6 mới). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Common/IdentityFixture.cs | 113 ++++++++ .../Services/PeTwoStageApprovalTests.cs | 257 ++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 tests/SolutionErp.Infrastructure.Tests/Common/IdentityFixture.cs create mode 100644 tests/SolutionErp.Infrastructure.Tests/Services/PeTwoStageApprovalTests.cs diff --git a/tests/SolutionErp.Infrastructure.Tests/Common/IdentityFixture.cs b/tests/SolutionErp.Infrastructure.Tests/Common/IdentityFixture.cs new file mode 100644 index 0000000..8c3e69e --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Common/IdentityFixture.cs @@ -0,0 +1,113 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Domain.Identity; +using SolutionErp.Infrastructure.Persistence; + +namespace SolutionErp.Infrastructure.Tests.Common; + +// Identity-aware fixture cho tests cần UserManager + RoleManager. +// +// Tại sao tách khỏi SqliteDbFixture? +// - SqliteDbFixture chỉ EF + DbContext (đủ cho code generator tests). +// - Service tests cần Identity stack: UserManager.FindByIdAsync, GetRolesAsync, +// CreateAsync, AddToRolesAsync — phụ thuộc IUserStore + IRoleStore + hashers +// + validators registered qua AddIdentityCore + AddRoles + AddEntityFrameworkStores. +// - Single connection mỗi fixture → DB persists across UserManager.Save calls. +// +// Pattern: ServiceProvider build từ AddIdentityCore. Test gọi GetRequired để +// resolve UserManager, DbContext, etc. EnsureCreated() build schema từ model +// (skip migrations vì test isolated). +public sealed class IdentityFixture : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly ServiceProvider _root; + public IServiceProvider Services { get; } // scoped to single test scope (shared across tests in class) + + public IdentityFixture() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var services = new ServiceCollection(); + services.AddLogging(); + + // Manual options + factory để inject TestApplicationDbContext qua type + // ApplicationDbContext (Identity EF stores expect base type). + var connection = _connection; + services.AddScoped(_ => + { + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .EnableSensitiveDataLogging() + .Options; + return new TestApplicationDbContext(options); + }); + services.AddScoped(sp => + (TestApplicationDbContext)sp.GetRequiredService()); + services.AddScoped(sp => + sp.GetRequiredService()); + + services.AddIdentityCore(opt => + { + opt.Password.RequireDigit = false; + opt.Password.RequireLowercase = false; + opt.Password.RequireUppercase = false; + opt.Password.RequireNonAlphanumeric = false; + opt.Password.RequiredLength = 4; + }) + .AddRoles() + .AddEntityFrameworkStores(); + + _root = services.BuildServiceProvider(); + Services = _root.CreateScope().ServiceProvider; + + var ctx = Services.GetRequiredService(); + ctx.Database.EnsureCreated(); + } + + // Helper: tạo user + assign roles + gán DepartmentId. Reuse trong nhiều test. + public async Task CreateUserAsync( + string email, + string fullName, + Guid? departmentId, + string[] roles, + bool canBypassReview = false) + { + var um = Services.GetRequiredService>(); + var rm = Services.GetRequiredService>(); + + // Ensure roles exist (idempotent). + foreach (var role in roles) + { + if (!await rm.RoleExistsAsync(role)) + await rm.CreateAsync(new Role { Id = Guid.NewGuid(), Name = role }); + } + + var user = new User + { + Id = Guid.NewGuid(), + UserName = email, + Email = email, + EmailConfirmed = true, + FullName = fullName, + DepartmentId = departmentId, + CanBypassReview = canBypassReview, + IsActive = true, + }; + var created = await um.CreateAsync(user, "Test@123"); + if (!created.Succeeded) + throw new InvalidOperationException("CreateUserAsync failed: " + string.Join(",", created.Errors.Select(e => e.Description))); + if (roles.Length > 0) + await um.AddToRolesAsync(user, roles); + return user; + } + + public void Dispose() + { + _root.Dispose(); + _connection.Dispose(); + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PeTwoStageApprovalTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PeTwoStageApprovalTests.cs new file mode 100644 index 0000000..2cff73c --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PeTwoStageApprovalTests.cs @@ -0,0 +1,257 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.Notifications; +using SolutionErp.Domain.Common; +using SolutionErp.Domain.Contracts; +using SolutionErp.Domain.Identity; +using SolutionErp.Domain.Notifications; +using SolutionErp.Domain.PurchaseEvaluations; +using SolutionErp.Infrastructure.Services; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Services; + +// Tests cho 2-stage department approval logic ở PurchaseEvaluationWorkflowService. +// Cover bug fix anh Kiệt: NV.PRO duyệt phase ChoPurchasing → BLOCK transition. +// TPB.PRO confirm → ALLOW transition. +// +// Pattern: dùng IdentityFixture (Identity stack + DbContext SQLite) để +// test thật end-to-end service thay vì mock. +public class PeTwoStageApprovalTests : IClassFixture +{ + private readonly IdentityFixture _fx; + private readonly TestApplicationDbContext _db; + private readonly UserManager _userManager; + private readonly PurchaseEvaluationWorkflowService _service; + private readonly Guid _deptPro; + private readonly Guid _deptCcm; + + public PeTwoStageApprovalTests(IdentityFixture fx) + { + _fx = fx; + _db = fx.Services.GetRequiredService(); + _userManager = fx.Services.GetRequiredService>(); + + // Seed 2 departments (idempotent — check trước khi insert vì fixture + // shared across tests trong class). + _deptPro = SeedDept("PRO", "Phòng Cung ứng"); + _deptCcm = SeedDept("CCM", "Phòng Kiểm soát chi phí"); + + var clock = new FixedDateTime(new DateTime(2026, 5, 4, 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 SeedPeAsync(PurchaseEvaluationPhase phase, Guid? projectId = null) + { + // Project required by FK constraint. + 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-{Random.Shared.Next(10000):D4}", + Name = "Test project", + }); + } + + var pe = new PurchaseEvaluation + { + Id = Guid.NewGuid(), + Type = PurchaseEvaluationType.DuyetNcc, + Phase = phase, + TenGoiThau = "Test gói thầu", + ProjectId = pid, + }; + _db.PurchaseEvaluations.Add(pe); + await _db.SaveChangesAsync(); + return pe; + } + + [Fact] + public async Task NV_Review_Blocks_Phase_Transition() + { + // Arrange: NV.PRO (role Procurement, dept PRO, NOT DeptManager). + var nv = await _fx.CreateUserAsync( + $"nv-{Guid.NewGuid():N}@test", "NV PRO", _deptPro, ["Procurement"]); + var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing); + + // Act: NV approve to ChoCCM. + await _service.TransitionAsync( + pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"], + ApprovalDecision.Approve, "review"); + + // Assert: phase KHÔNG đổi, có 1 row Stage=Review. + var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoPurchasing); + + var deptApprovals = await _db.PurchaseEvaluationDepartmentApprovals + .Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync(); + deptApprovals.Should().HaveCount(1); + deptApprovals[0].Stage.Should().Be(ApprovalStage.Review); + deptApprovals[0].DepartmentId.Should().Be(_deptPro); + deptApprovals[0].IsBypassed.Should().BeFalse(); + + var approvals = await _db.PurchaseEvaluationApprovals + .Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync(); + approvals.Should().HaveCount(1); + approvals[0].Comment.Should().StartWith("[Review NV]"); + } + + [Fact] + public async Task TPB_Confirm_After_NV_Review_Allows_Transition() + { + // Arrange: NV review trước, sau đó TPB confirm. + var nv = await _fx.CreateUserAsync( + $"nv-{Guid.NewGuid():N}@test", "NV PRO", _deptPro, ["Procurement"]); + var tpb = await _fx.CreateUserAsync( + $"tpb-{Guid.NewGuid():N}@test", "TPB PRO", _deptPro, ["DeptManager", "Procurement"]); + var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing); + + await _service.TransitionAsync( + pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"], + ApprovalDecision.Approve, "review NV"); + + // Re-fetch tracked entity (service modifies state ở Phase prior). + pe = await _db.PurchaseEvaluations.FirstAsync(x => x.Id == pe.Id); + + // Act: TPB confirm. + await _service.TransitionAsync( + pe, PurchaseEvaluationPhase.ChoCCM, tpb.Id, ["DeptManager", "Procurement"], + ApprovalDecision.Approve, "confirm TPB"); + + // Assert: phase đổi, có 2 row (Review + Confirm). + var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM); + + var deptApprovals = await _db.PurchaseEvaluationDepartmentApprovals + .Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync(); + deptApprovals.Should().HaveCount(2); + deptApprovals.Should().Contain(a => a.Stage == ApprovalStage.Review && a.ApproverUserId == nv.Id); + deptApprovals.Should().Contain(a => a.Stage == ApprovalStage.Confirm && a.ApproverUserId == tpb.Id && !a.IsBypassed); + } + + [Fact] + public async Task NV_With_BypassReview_Allows_Transition_With_IsBypassed_True() + { + // Arrange: NV CanBypassReview=true. + var nv = await _fx.CreateUserAsync( + $"nv-bypass-{Guid.NewGuid():N}@test", "NV PRO bypass", + _deptPro, ["Procurement"], canBypassReview: true); + var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing); + + // Act: bypass user approve → đẩy thẳng Stage=Confirm. + await _service.TransitionAsync( + pe, PurchaseEvaluationPhase.ChoCCM, nv.Id, ["Procurement"], + ApprovalDecision.Approve, "bypass approve"); + + // Assert: phase đổi, có 1 row Stage=Confirm + IsBypassed=true. + var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM); + + var deptApprovals = await _db.PurchaseEvaluationDepartmentApprovals + .Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync(); + deptApprovals.Should().HaveCount(1); + deptApprovals[0].Stage.Should().Be(ApprovalStage.Confirm); + deptApprovals[0].IsBypassed.Should().BeTrue(); + deptApprovals[0].ApproverRoleSnapshot.Should().Be("NV(bypass)"); + } + + [Fact] + public async Task Admin_Skips_TwoStage_Logic_Entirely() + { + // Arrange: Admin role. + var admin = await _fx.CreateUserAsync( + $"admin-{Guid.NewGuid():N}@test", "Admin user", null, ["Admin"]); + var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoPurchasing); + + // Act: Admin approve. + await _service.TransitionAsync( + pe, PurchaseEvaluationPhase.ChoCCM, admin.Id, ["Admin"], + ApprovalDecision.Approve, "admin force"); + + // Assert: phase đổi, KHÔNG có DepartmentApproval row. + var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM); + + var deptApprovals = await _db.PurchaseEvaluationDepartmentApprovals + .Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync(); + deptApprovals.Should().BeEmpty(); + } + + [Fact] + public async Task Reject_Sets_RejectedFromPhase_And_Forces_DangSoanThao() + { + // Arrange: PE phase=ChoCCM. Drafter reject. + var actor = await _fx.CreateUserAsync( + $"ccm-{Guid.NewGuid():N}@test", "CCM TPB", _deptCcm, ["DeptManager", "CostControl"]); + var pe = await SeedPeAsync(PurchaseEvaluationPhase.ChoCCM); + + // Act: reject (target irrelevant — service forces về DangSoanThao). + await _service.TransitionAsync( + pe, PurchaseEvaluationPhase.TuChoi, actor.Id, ["DeptManager", "CostControl"], + ApprovalDecision.Reject, "không phù hợp"); + + // Assert. + var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + fresh.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao); + fresh.RejectedFromPhase.Should().Be(PurchaseEvaluationPhase.ChoCCM); + } + + [Fact] + public async Task Resume_After_Reject_Jumps_Back_To_RejectedPhase() + { + // Arrange: PE rejected từ ChoCCM, đang ở DangSoanThao + RejectedFromPhase=ChoCCM. + var drafter = await _fx.CreateUserAsync( + $"drafter-{Guid.NewGuid():N}@test", "Drafter", _deptPro, ["Drafter"]); + var pe = await SeedPeAsync(PurchaseEvaluationPhase.DangSoanThao); + pe.RejectedFromPhase = PurchaseEvaluationPhase.ChoCCM; + await _db.SaveChangesAsync(); + + // Act: drafter trình lại từ DangSoanThao → ChoPurchasing (target không + // quan trọng vì resume sẽ override = RejectedFromPhase). Note: service + // jump tới ChoCCM, nhưng nếu actor có dept thì sẽ hit 2-stage logic. + // Simpler: dùng admin để bypass 2-stage gate khi resume cũng OK. + var admin = await _fx.CreateUserAsync( + $"admin-resume-{Guid.NewGuid():N}@test", "Admin resume", null, ["Admin"]); + await _service.TransitionAsync( + pe, PurchaseEvaluationPhase.ChoPurchasing, admin.Id, ["Admin"], + ApprovalDecision.Approve, "drafter resume"); + + // Assert: phase jump tới ChoCCM (không phải ChoPurchasing target), + // RejectedFromPhase=null. + var fresh = await _db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + fresh.Phase.Should().Be(PurchaseEvaluationPhase.ChoCCM); + fresh.RejectedFromPhase.Should().BeNull(); + } +} + +// Stub notification service — tests không cần verify notification path +// (best effort try/catch ở service đã cover fail case). +internal class FakeNotificationService : INotificationService +{ + public Task NotifyAsync(Guid userId, NotificationType type, string title, + string? description = null, string? href = null, Guid? refId = null, + CancellationToken ct = default) => Task.CompletedTask; + + public Task NotifyManyAsync(IEnumerable userIds, NotificationType type, + string title, string? description = null, string? href = null, + Guid? refId = null, CancellationToken ct = default) => Task.CompletedTask; +}