diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/DepartmentTreeTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/DepartmentTreeTests.cs new file mode 100644 index 0000000..41bac23 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/DepartmentTreeTests.cs @@ -0,0 +1,301 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.Master.Departments; +using SolutionErp.Domain.Master; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Application; + +// S66 test-after (UAT mode — Department.ParentId + cây tổ chức shipped S65 Mig 51). +// File `DepartmentFeatures.cs`: +// - GetDepartmentTreeQueryHandler: ráp cây từ ParentId loose-Guid + rollup +// TotalEmployeeCount = Direct + Σ con đệ quy (nguồn = User.DepartmentId, active). +// - UpdateDepartmentCommandHandler (line 198-213): cycle-guard 2 lớp khi gán ParentId: +// (1) ParentId == Id → ConflictException "Phòng không thể là cấp cha của chính nó." +// (2) ParentId là con-cháu (đi ngược chuỗi cha, gặp lại Id) → ConflictException +// "Không thể gán: sẽ tạo vòng lặp phân cấp phòng ban." +// +// CHỐT theo CODE (single source of truth, S34 rule): +// - DirectEmployeeCount = User.DepartmentId == dept.Id AND IsActive=true. +// EmployeeProfile KHÔNG có DepartmentId — org-chart đếm trên User entity (Mig 11). +// - Children sort theo Code (StringComparer.Ordinal). +// - Root = ParentId null HOẶC trỏ phòng không tồn tại (orphan-safe coi như root). +// - Cycle-guard RollupAndSort dùng HashSet visited → data cũ đã lỗi cha-trỏ-vòng vẫn +// không treo (cắt vòng, node đã thăm trả null). +// +// Vì handler đếm User.DepartmentId → seed User thật qua IdentityFixture (Identity stack). +// Cycle-guard test KHÔNG cần User → SqliteDbFixture đủ nhẹ. +public class DepartmentTreeTests +{ + // ---- Seed helper: tạo Department row, trả entity để lấy Id ---- + private static Department Dept(string code, string name, Guid? parentId = null) + => new() + { + Id = Guid.NewGuid(), + Code = code, + Name = name, + ParentId = parentId, + }; + + // ============================================================ + // GetDepartmentTree — ráp cây + rollup TotalEmployeeCount + // ============================================================ + + [Fact] + public async Task Tree_NestedHierarchy_ParentContainsChildren_SortedByCode() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + // Cây: ROOT + // ├─ A + // │ └─ A1 + // └─ B + var root = Dept("ROOT", "Công ty"); + var a = Dept("A", "Phòng A", root.Id); + var a1 = Dept("A1", "Tổ A1", a.Id); + var b = Dept("B", "Phòng B", root.Id); + // Add lệch thứ tự Code để chứng minh sort ổn định (B trước A trong insert). + db.Departments.AddRange(b, a1, root, a); + await db.SaveChangesAsync(CancellationToken.None); + + var handler = new GetDepartmentTreeQueryHandler(db); + var tree = await handler.Handle(new GetDepartmentTreeQuery(), CancellationToken.None); + + // 1 root duy nhất. + tree.Should().HaveCount(1); + var rootNode = tree[0]; + rootNode.Id.Should().Be(root.Id); + rootNode.Code.Should().Be("ROOT"); + + // Children của ROOT sort theo Code: A trước B. + rootNode.Children.Select(c => c.Code).Should().Equal("A", "B"); + + // A chứa A1 (cha chứa con đúng phân cấp). + var aNode = rootNode.Children.First(c => c.Code == "A"); + aNode.Children.Should().ContainSingle().Which.Code.Should().Be("A1"); + aNode.Children[0].ParentId.Should().Be(a.Id); + + // B là lá (không con). + rootNode.Children.First(c => c.Code == "B").Children.Should().BeEmpty(); + } + + [Fact] + public async Task Tree_RollupTotalEmployeeCount_DirectPlusAllDescendants() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + // Cây: ROOT (2 NV trực tiếp) + // └─ A (3 NV) + // └─ A1 (1 NV) + // Total: A1=1, A=3+1=4, ROOT=2+4=6. + var root = Dept("ROOT", "Công ty"); + var a = Dept("A", "Phòng A", root.Id); + var a1 = Dept("A1", "Tổ A1", a.Id); + db.Departments.AddRange(root, a, a1); + await db.SaveChangesAsync(CancellationToken.None); + + // Seed User active per dept (đếm theo User.DepartmentId + IsActive). + await SeedUsersAsync(fix, root.Id, 2); + await SeedUsersAsync(fix, a.Id, 3); + await SeedUsersAsync(fix, a1.Id, 1); + + var handler = new GetDepartmentTreeQueryHandler(db); + var tree = await handler.Handle(new GetDepartmentTreeQuery(), CancellationToken.None); + + var rootNode = tree.Single(); + var aNode = rootNode.Children.Single(c => c.Code == "A"); + var a1Node = aNode.Children.Single(c => c.Code == "A1"); + + // Direct = NV trực thuộc. + rootNode.DirectEmployeeCount.Should().Be(2); + aNode.DirectEmployeeCount.Should().Be(3); + a1Node.DirectEmployeeCount.Should().Be(1); + + // Total = Direct + Σ con đệ quy (rollup). + a1Node.TotalEmployeeCount.Should().Be(1); + aNode.TotalEmployeeCount.Should().Be(4); + rootNode.TotalEmployeeCount.Should().Be(6); + } + + [Fact] + public async Task Tree_InactiveUser_NotCountedInDirectOrTotal() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + var root = Dept("ROOT", "Công ty"); + db.Departments.Add(root); + await db.SaveChangesAsync(CancellationToken.None); + + // 2 active + 1 inactive trong cùng phòng → chỉ đếm 2 active. + await SeedUsersAsync(fix, root.Id, 2); + await SeedUsersAsync(fix, root.Id, 1, isActive: false); + + var handler = new GetDepartmentTreeQueryHandler(db); + var tree = await handler.Handle(new GetDepartmentTreeQuery(), CancellationToken.None); + + var rootNode = tree.Single(); + rootNode.DirectEmployeeCount.Should().Be(2); + rootNode.TotalEmployeeCount.Should().Be(2); + } + + [Fact] + public async Task Tree_OrphanParentId_TreatedAsRoot() + { + // ParentId trỏ tới Guid không tồn tại (data cũ / phòng cha đã xoá) → + // node coi như root (orphan-safe) thay vì biến mất. + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + var ghostParentId = Guid.NewGuid(); // không seed vào DB + var orphan = Dept("ORPH", "Phòng mồ côi", ghostParentId); + db.Departments.Add(orphan); + await db.SaveChangesAsync(CancellationToken.None); + + var handler = new GetDepartmentTreeQueryHandler(db); + var tree = await handler.Handle(new GetDepartmentTreeQuery(), CancellationToken.None); + + tree.Should().ContainSingle().Which.Code.Should().Be("ORPH"); + } + + // ============================================================ + // Update cycle-guard — invariant quan trọng nhất + // ============================================================ + + [Fact] + public async Task Update_SetParentToSelf_ThrowsConflict() + { + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + var d = Dept("X", "Phòng X"); + db.Departments.Add(d); + await db.SaveChangesAsync(CancellationToken.None); + + var handler = new UpdateDepartmentCommandHandler(db); + var cmd = new UpdateDepartmentCommand(d.Id, "X", "Phòng X", null, null, ParentId: d.Id); + + var act = async () => await handler.Handle(cmd, CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*không thể là cấp cha của chính nó*"); + + // Side-effect: ParentId KHÔNG bị mutate (vẫn null sau khi reject). + var reload = await db.Departments.AsNoTracking().FirstAsync(x => x.Id == d.Id); + reload.ParentId.Should().BeNull(); + } + + [Fact] + public async Task Update_SetParentToDirectChild_CreatesCycle_ThrowsConflict() + { + // A là cha của B. Gán ParentId(A) = B → vòng A→B→A. Phải reject. + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + var a = Dept("A", "Phòng A"); + var b = Dept("B", "Phòng B", a.Id); // B con của A + db.Departments.AddRange(a, b); + await db.SaveChangesAsync(CancellationToken.None); + + var handler = new UpdateDepartmentCommandHandler(db); + // Update A: gán cha = B (con của A) → tạo vòng. + var cmd = new UpdateDepartmentCommand(a.Id, "A", "Phòng A", null, null, ParentId: b.Id); + + var act = async () => await handler.Handle(cmd, CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*sẽ tạo vòng lặp phân cấp*"); + + // A.ParentId vẫn null (không mutate khi reject). + var reloadA = await db.Departments.AsNoTracking().FirstAsync(x => x.Id == a.Id); + reloadA.ParentId.Should().BeNull(); + } + + [Fact] + public async Task Update_SetParentToDeepDescendant_CreatesCycle_ThrowsConflict() + { + // Chuỗi sâu: A → B → C (A là tổ tiên của C). Gán ParentId(A) = C → + // vòng A→…→C→A. Guard đi ngược chuỗi cha từ C gặp lại A → reject. + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + var a = Dept("A", "Phòng A"); + var b = Dept("B", "Phòng B", a.Id); + var c = Dept("C", "Tổ C", b.Id); + db.Departments.AddRange(a, b, c); + await db.SaveChangesAsync(CancellationToken.None); + + var handler = new UpdateDepartmentCommandHandler(db); + var cmd = new UpdateDepartmentCommand(a.Id, "A", "Phòng A", null, null, ParentId: c.Id); + + var act = async () => await handler.Handle(cmd, CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*sẽ tạo vòng lặp phân cấp*"); + } + + [Fact] + public async Task Update_SetParentToUnrelatedDepartment_Succeeds() + { + // Gán cha hợp lệ (phòng độc lập, KHÔNG con-cháu) → pass guard, persist ParentId. + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + var a = Dept("A", "Phòng A"); + var b = Dept("B", "Phòng B"); // độc lập, không quan hệ + db.Departments.AddRange(a, b); + await db.SaveChangesAsync(CancellationToken.None); + + var handler = new UpdateDepartmentCommandHandler(db); + // Gán A làm con của B — hợp lệ (B không phải con-cháu của A). + var cmd = new UpdateDepartmentCommand(a.Id, "A", "Phòng A", null, null, ParentId: b.Id); + + await handler.Handle(cmd, CancellationToken.None); + + var reloadA = await db.Departments.AsNoTracking().FirstAsync(x => x.Id == a.Id); + reloadA.ParentId.Should().Be(b.Id); + } + + [Fact] + public async Task Update_ClearParentToNull_Succeeds() + { + // ParentId null = promote về root — guard không chạy (chỉ chạy khi có ParentId). + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + var a = Dept("A", "Phòng A"); + var b = Dept("B", "Phòng B", a.Id); // B con của A + db.Departments.AddRange(a, b); + await db.SaveChangesAsync(CancellationToken.None); + + var handler = new UpdateDepartmentCommandHandler(db); + var cmd = new UpdateDepartmentCommand(b.Id, "B", "Phòng B", null, null, ParentId: null); + + await handler.Handle(cmd, CancellationToken.None); + + var reloadB = await db.Departments.AsNoTracking().FirstAsync(x => x.Id == b.Id); + reloadB.ParentId.Should().BeNull(); + } + + // ---- Seed N active/inactive User gán DepartmentId (đếm org-chart) ---- + private static async Task SeedUsersAsync(IdentityFixture fix, Guid deptId, int count, bool isActive = true) + { + var db = fix.Services.GetRequiredService(); + for (var i = 0; i < count; i++) + { + // CreateUserAsync luôn set IsActive=true → với inactive, tạo xong update flag. + var suffix = Guid.NewGuid().ToString("N")[..8]; + var user = await fix.CreateUserAsync( + $"u-{suffix}@test.local", $"NV {suffix}", departmentId: deptId, roles: System.Array.Empty()); + if (!isActive) + { + var tracked = await db.Users.FirstAsync(u => u.Id == user.Id); + tracked.IsActive = false; + await db.SaveChangesAsync(CancellationToken.None); + } + } + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/HrmProfilePermissionSeedTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/HrmProfilePermissionSeedTests.cs new file mode 100644 index 0000000..0f66cb7 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/HrmProfilePermissionSeedTests.cs @@ -0,0 +1,253 @@ +using System.Reflection; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using SolutionErp.Domain.Identity; +using SolutionErp.Infrastructure.Persistence; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Application; + +// S66 test-after (UAT mode — "public Hồ sơ NS nhưng ẩn Dashboard/Config" shipped S65). +// DbInitializer permission-seed chain (SeedAsync order, line 2033-2046): +// 1. SeedAllRolesReviewReadPermissionsAsync (grant Master/Catalogs/Pe_* read) +// 2. RevokeTemporarilyHiddenModulesAsync (S58) → set MỌI Hrm*/Off*/Personal = false +// cho non-Admin (StartsWith("Hrm") bắt cả Hrm, Hrm_HoSo, Hrm_Dashboard, Hrm_Config*). +// 3. SeedAllRolesHrmProfileReadPermissionsAsync (S65) → CHẠY SAU để THẮNG revoke, +// nâng RIÊNG 2 key { Hrm, Hrm_HoSo } = CanRead=true (upgrade-only). +// +// Kết quả mong đợi (anh chốt public module Nhân sự, ẩn Dashboard + 6 catalog config): +// - non-Admin role: Hrm=true, Hrm_HoSo=true (mở lại để tra cứu hồ sơ NS) +// - non-Admin role: Hrm_Dashboard=false, Hrm_Config*=false (revoke vẫn che) +// - Admin role: KHÔNG bị revoke (giữ nguyên — quản trị/chuẩn bị dữ liệu) +// +// 2 method này `private static` trong DbInitializer → invoke qua REFLECTION +// (BindingFlags.NonPublic|Static). Test ĐÚNG behavior code thật, KHÔNG re-implement. +// Signature cả 2: (ApplicationDbContext db, RoleManager roleManager, ILogger logger). +// +// FK lưu ý (PermissionConfiguration): Permission.MenuKey → MenuItem.Key (Cascade) + +// Permission.RoleId → Role (Cascade). → PHẢI seed MenuItem rows + Role trước khi seed +// Permission rows (nếu không SQLite FK Error 19). +public class HrmProfilePermissionSeedTests +{ + // 6 leaf config (đại diện Hrm_Config*) — tất cả phải GIỮ ẩn sau chain. + private static readonly string[] ConfigKeys = + { + MenuKeys.HrmConfig, + MenuKeys.HrmConfigLeaveTypes, + MenuKeys.HrmConfigHolidays, + MenuKeys.HrmConfigShifts, + MenuKeys.HrmConfigOtPolicies, + MenuKeys.HrmConfigVehicles, + MenuKeys.HrmConfigDrivers, + }; + + // Mọi key Hrm* dùng trong test (cần MenuItem row vì FK). + private static readonly string[] AllHrmKeys = + new[] { MenuKeys.Hrm, MenuKeys.HrmHoSo, MenuKeys.HrmDashboard } + .Concat(ConfigKeys).ToArray(); + + private static async Task InvokeRevokeAsync(IdentityFixture fix) + => await InvokePrivateSeedAsync(fix, "RevokeTemporarilyHiddenModulesAsync"); + + private static async Task InvokeHrmProfileSeedAsync(IdentityFixture fix) + => await InvokePrivateSeedAsync(fix, "SeedAllRolesHrmProfileReadPermissionsAsync"); + + // Reflection invoke private static (ApplicationDbContext, RoleManager, ILogger). + private static async Task InvokePrivateSeedAsync(IdentityFixture fix, string methodName) + { + var db = fix.Services.GetRequiredService(); + var rm = fix.Services.GetRequiredService>(); + var mi = typeof(DbInitializer).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static); + mi.Should().NotBeNull($"DbInitializer.{methodName} phải tồn tại (private static) — đổi signature thì cập nhật test"); + var task = (Task)mi!.Invoke(null, new object[] { db, rm, NullLogger.Instance })!; + await task; + } + + // Ensure roles tồn tại (Admin + non-Admin). Trả (adminId, nonAdminId). + private static async Task<(Guid adminId, Guid nonAdminId)> SeedRolesAsync(IdentityFixture fix) + { + var rm = fix.Services.GetRequiredService>(); + var admin = new Role { Id = Guid.NewGuid(), Name = AppRoles.Admin }; + var drafter = new Role { Id = Guid.NewGuid(), Name = AppRoles.Drafter }; + await rm.CreateAsync(admin); + await rm.CreateAsync(drafter); + return (admin.Id, drafter.Id); + } + + // Seed MenuItem rows (FK target cho Permission.MenuKey). + private static async Task SeedMenuItemsAsync(TestApplicationDbContext db) + { + foreach (var key in AllHrmKeys) + db.MenuItems.Add(new MenuItem { Key = key, Label = key }); + await db.SaveChangesAsync(CancellationToken.None); + } + + // Seed 1 Permission row. + private static void AddPerm(TestApplicationDbContext db, Guid roleId, string menuKey, bool canRead) + => db.Permissions.Add(new Permission + { + RoleId = roleId, + MenuKey = menuKey, + CanRead = canRead, + CanCreate = false, + CanUpdate = false, + CanDelete = false, + }); + + private static async Task CanReadAsync(TestApplicationDbContext db, Guid roleId, string menuKey) + { + var row = await db.Permissions.AsNoTracking() + .FirstOrDefaultAsync(p => p.RoleId == roleId && p.MenuKey == menuKey); + return row?.CanRead ?? false; + } + + // ============================================================ + // Full chain: pre-grant Hrm* → Revoke → HrmProfileSeed + // ============================================================ + + [Fact] + public async Task Chain_NonAdmin_HrmAndHoSo_ReadTrue_DashboardAndConfig_StayHidden() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var (adminId, nonAdminId) = await SeedRolesAsync(fix); + await SeedMenuItemsAsync(db); + + // PRE-STATE: non-Admin từng được grant CanRead=true trên MỌI Hrm* (mô phỏng + // seed cũ) → để revoke có gì đó để thu hồi. Admin cũng có (sẽ KHÔNG bị revoke). + foreach (var key in AllHrmKeys) + { + AddPerm(db, nonAdminId, key, canRead: true); + AddPerm(db, adminId, key, canRead: true); + } + await db.SaveChangesAsync(CancellationToken.None); + + // CHAIN đúng thứ tự SeedAsync. + await InvokeRevokeAsync(fix); + await InvokeHrmProfileSeedAsync(fix); + + // non-Admin: Hrm + Hrm_HoSo mở lại (public Hồ sơ NS). + (await CanReadAsync(db, nonAdminId, MenuKeys.Hrm)).Should().BeTrue("root Nhân sự mở lại cho user thường"); + (await CanReadAsync(db, nonAdminId, MenuKeys.HrmHoSo)).Should().BeTrue("Hồ sơ NS public read-only"); + + // non-Admin: Dashboard + 6 config VẪN ẩn (revoke che, seed không nâng). + (await CanReadAsync(db, nonAdminId, MenuKeys.HrmDashboard)).Should().BeFalse("Dashboard NS giữ ẩn"); + foreach (var key in ConfigKeys) + (await CanReadAsync(db, nonAdminId, key)).Should().BeFalse($"{key} giữ ẩn (chỉ Admin)"); + } + + [Fact] + public async Task Chain_Admin_NotRevoked_KeepsAllHrmReadAfterChain() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var (adminId, nonAdminId) = await SeedRolesAsync(fix); + await SeedMenuItemsAsync(db); + + foreach (var key in AllHrmKeys) + { + AddPerm(db, adminId, key, canRead: true); + AddPerm(db, nonAdminId, key, canRead: true); + } + await db.SaveChangesAsync(CancellationToken.None); + + await InvokeRevokeAsync(fix); + await InvokeHrmProfileSeedAsync(fix); + + // Admin GIỮ nguyên CanRead=true trên mọi Hrm* (kể cả Dashboard + Config). + foreach (var key in AllHrmKeys) + (await CanReadAsync(db, adminId, key)).Should().BeTrue($"Admin KHÔNG bị revoke trên {key}"); + } + + // ============================================================ + // SeedAllRolesHrmProfileReadPermissionsAsync — isolated behavior + // ============================================================ + + [Fact] + public async Task ProfileSeed_UpgradesExistingFalseRow_RaisesCanReadTrue() + { + // Upgrade-only path (line 2224): row đã tồn tại CanRead=false (revoke vừa set) + // → nâng lên true. Mô phỏng: chỉ seed Hrm/Hrm_HoSo = false rồi gọi profile-seed. + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var (_, nonAdminId) = await SeedRolesAsync(fix); + await SeedMenuItemsAsync(db); + + AddPerm(db, nonAdminId, MenuKeys.Hrm, canRead: false); + AddPerm(db, nonAdminId, MenuKeys.HrmHoSo, canRead: false); + await db.SaveChangesAsync(CancellationToken.None); + + await InvokeHrmProfileSeedAsync(fix); + + (await CanReadAsync(db, nonAdminId, MenuKeys.Hrm)).Should().BeTrue(); + (await CanReadAsync(db, nonAdminId, MenuKeys.HrmHoSo)).Should().BeTrue(); + } + + [Fact] + public async Task ProfileSeed_CreatesMissingRow_ReadTrueOtherFlagsFalse() + { + // Row chưa có (DB mới) → tạo CanRead=true, Create/Update/Delete=false (line 2229). + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var (_, nonAdminId) = await SeedRolesAsync(fix); + await SeedMenuItemsAsync(db); + // KHÔNG seed Permission row nào cho Hrm/Hrm_HoSo. + + await InvokeHrmProfileSeedAsync(fix); + + var hrm = await db.Permissions.AsNoTracking() + .FirstOrDefaultAsync(p => p.RoleId == nonAdminId && p.MenuKey == MenuKeys.Hrm); + hrm.Should().NotBeNull("profile-seed tạo row mới khi chưa tồn tại"); + hrm!.CanRead.Should().BeTrue(); + hrm.CanCreate.Should().BeFalse(); + hrm.CanUpdate.Should().BeFalse(); + hrm.CanDelete.Should().BeFalse(); + } + + [Fact] + public async Task ProfileSeed_DoesNotTouchDashboardOrConfigKeys() + { + // Profile-seed CHỈ đụng { Hrm, Hrm_HoSo } — KHÔNG tạo/nâng Dashboard hay Config*. + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var (_, nonAdminId) = await SeedRolesAsync(fix); + await SeedMenuItemsAsync(db); + // KHÔNG seed Permission rows nào. + + await InvokeHrmProfileSeedAsync(fix); + + // Dashboard + Config* không có row nào được tạo (CanRead mặc định false / no row). + (await CanReadAsync(db, nonAdminId, MenuKeys.HrmDashboard)).Should().BeFalse(); + foreach (var key in ConfigKeys) + (await CanReadAsync(db, nonAdminId, key)).Should().BeFalse(); + + var dashRow = await db.Permissions.AsNoTracking() + .FirstOrDefaultAsync(p => p.RoleId == nonAdminId && p.MenuKey == MenuKeys.HrmDashboard); + dashRow.Should().BeNull("profile-seed KHÔNG tạo row cho Hrm_Dashboard"); + } + + // ============================================================ + // RevokeTemporarilyHiddenModulesAsync — isolated behavior + // ============================================================ + + [Fact] + public async Task Revoke_NonAdmin_ClearsAllHrmReadFlags() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var (_, nonAdminId) = await SeedRolesAsync(fix); + await SeedMenuItemsAsync(db); + + foreach (var key in AllHrmKeys) + AddPerm(db, nonAdminId, key, canRead: true); + await db.SaveChangesAsync(CancellationToken.None); + + await InvokeRevokeAsync(fix); + + // MỌI Hrm* về false cho non-Admin (gồm cả Hrm + Hrm_HoSo TRƯỚC khi profile-seed nâng lại). + foreach (var key in AllHrmKeys) + (await CanReadAsync(db, nonAdminId, key)).Should().BeFalse($"revoke thu hồi {key} khỏi non-Admin"); + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PeHoSoLinkTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PeHoSoLinkTests.cs new file mode 100644 index 0000000..fc5e2da --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/PeHoSoLinkTests.cs @@ -0,0 +1,215 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Application.PurchaseEvaluations; +using SolutionErp.Domain.PurchaseEvaluations; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Application; + +// S66 test-after (UAT mode — PE HoSoLink shipped S65 Mig 52). +// `PurchaseEvaluation.HoSoLink string?` — 1 hyperlink tới thư mục hồ sơ NAS, max 1000, +// nullable, KHÔNG entity con / index. +// +// CHỐT theo CODE (single source of truth, S34 rule) — đọc PurchaseEvaluationFeatures.cs: +// - Create handler line 140: HoSoLink = request.HoSoLink (absolute-set, default null) +// - Update handler line 274: entity.HoSoLink = request.HoSoLink (ABSOLUTE-SET như +// MoTa/DiaDiem — Section 1 text field, comment ghi rõ "null = clear link") +// - Validator: RuleFor(HoSoLink).MaximumLength(1000) — chỉ field-level, no cross-table. +// +// ⚠️ SPEC-DRIFT vs task brief S66 (test theo CODE, report drift): +// Task brief mục 2 nói "update null-safe (không null-hoá khi UpdateDraft thiếu field +// theo convention S42)". CODE THỰC TẾ thì HoSoLink KHÔNG null-safe — nó absolute-set +// giống MoTa/DiaDiem. CHỈ BudgetPeriodAmount/ExpectedRemainingAmount/WorkItemId mới +// null-safe (3 field đó dùng `if (request.X is not null)`). HoSoLink gán thẳng. +// → Test LOCK behavior absolute-set thật của code (UpdateDraft với HoSoLink=null → +// CLEAR link cũ). Đây là chủ đích của code (Section 1 FE luôn gửi đủ field text). +// +// UpdatePurchaseEvaluationDraftCommandHandler(IApplicationDbContext db, ICurrentUser cu) +// — 2 dep nhẹ. Phase guard: chỉ DangSoanThao / TraLai mới update được. +public class PeHoSoLinkTests +{ + private sealed class FakeCurrentUser : ICurrentUser + { + public Guid? UserId { get; init; } = Guid.NewGuid(); + public string? Email { get; init; } + public string? FullName { get; init; } = "Người soạn"; + public IReadOnlyList Roles { get; init; } = System.Array.Empty(); + public bool IsAuthenticated => UserId is not null; + } + + // Seed 1 PE Nháp với HoSoLink ban đầu (default null). Trả entity. + private static PurchaseEvaluation BuildPe( + PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.DangSoanThao, + string? hoSoLink = null, + string code = "PE-HSL-001") + => new() + { + Id = Guid.NewGuid(), + Type = PurchaseEvaluationType.DuyetNcc, + Phase = phase, + MaPhieu = code, + TenGoiThau = "Gói thầu test", + ProjectId = Guid.NewGuid(), + DrafterUserId = Guid.NewGuid(), + HoSoLink = hoSoLink, + }; + + // Update command với chỉ HoSoLink đổi, giữ field text khác như cũ. + private static UpdatePurchaseEvaluationDraftCommand UpdateCmd(Guid id, string? hoSoLink) + => new(id, "Gói thầu test", DiaDiem: null, MoTa: null, PaymentTerms: null, HoSoLink: hoSoLink); + + // ============================================================ + // EF persistence round-trip — create-path field persist đúng + // ============================================================ + + [Fact] + public async Task Persist_HoSoLinkSet_RoundTripsExactValue() + { + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + const string link = @"\\nas.solutions.local\HoSo\GoiThau\BeTong-2026"; + var pe = BuildPe(hoSoLink: link); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + var reload = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reload.HoSoLink.Should().Be(link); + } + + [Fact] + public async Task Persist_HoSoLinkNull_AllowedOptional() + { + // HoSoLink optional (nullable) — phiếu không có link vẫn lưu OK. + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + var pe = BuildPe(hoSoLink: null, code: "PE-HSL-002"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + var reload = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reload.HoSoLink.Should().BeNull(); + } + + [Fact] + public async Task Persist_HoSoLink1000Chars_RoundTripsAtMaxLength() + { + // MaxLength(1000) — boundary 1000 ký tự lưu nguyên (SQLite TEXT không cắt, + // assert giá trị giữ đủ; SQL Server prod nvarchar(1000) cũng chứa đủ 1000). + using var fix = new SqliteDbFixture(); + var db = fix.Db; + + var link = new string('x', 1000); + var pe = BuildPe(hoSoLink: link, code: "PE-HSL-003"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + var reload = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reload.HoSoLink.Should().HaveLength(1000); + } + + // ============================================================ + // UpdateDraft — set / clear HoSoLink (absolute-set theo CODE) + // ============================================================ + + [Fact] + public async Task UpdateDraft_SetHoSoLink_FromNull_Persists() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + var pe = BuildPe(hoSoLink: null, code: "PE-HSL-U1"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + const string newLink = @"\\nas\HoSo\PE-001"; + var handler = new UpdatePurchaseEvaluationDraftCommandHandler(db, new FakeCurrentUser()); + await handler.Handle(UpdateCmd(pe.Id, newLink), CancellationToken.None); + + var reload = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reload.HoSoLink.Should().Be(newLink); + } + + [Fact] + public async Task UpdateDraft_ChangeHoSoLink_OverwritesOldValue() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + var pe = BuildPe(hoSoLink: @"\\nas\HoSo\OLD", code: "PE-HSL-U2"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + const string newLink = @"\\nas\HoSo\NEW-2026"; + var handler = new UpdatePurchaseEvaluationDraftCommandHandler(db, new FakeCurrentUser()); + await handler.Handle(UpdateCmd(pe.Id, newLink), CancellationToken.None); + + var reload = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reload.HoSoLink.Should().Be(newLink); + } + + [Fact] + public async Task UpdateDraft_HoSoLinkNull_ClearsExistingLink_AbsoluteSet() + { + // ⚠️ SPEC-DRIFT cover: code ABSOLUTE-SET (entity.HoSoLink = request.HoSoLink), + // KHÔNG null-safe. UpdateDraft với HoSoLink=null → CLEAR link cũ (về null). + // Khác convention S42 (BudgetPeriodAmount/WorkItemId GIỮ giá trị cũ khi null). + // Test LOCK đúng behavior code (Section 1 FE luôn gửi đủ field → intent đúng). + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + var pe = BuildPe(hoSoLink: @"\\nas\HoSo\WILL-BE-CLEARED", code: "PE-HSL-U3"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + var handler = new UpdatePurchaseEvaluationDraftCommandHandler(db, new FakeCurrentUser()); + // HoSoLink default null trong UpdateCmd → clear. + await handler.Handle(UpdateCmd(pe.Id, hoSoLink: null), CancellationToken.None); + + var reload = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reload.HoSoLink.Should().BeNull("code absolute-set HoSoLink — null request = clear link (KHÔNG null-safe như Budget*/WorkItemId)"); + } + + [Fact] + public async Task UpdateDraft_OnTraLaiPhase_AllowsHoSoLinkUpdate() + { + // Phase guard cho phép Nháp + Trả lại → update HoSoLink ở TraLai cũng OK. + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + var pe = BuildPe(PurchaseEvaluationPhase.TraLai, hoSoLink: null, code: "PE-HSL-U4"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + const string link = @"\\nas\HoSo\TraLai-Update"; + var handler = new UpdatePurchaseEvaluationDraftCommandHandler(db, new FakeCurrentUser()); + await handler.Handle(UpdateCmd(pe.Id, link), CancellationToken.None); + + var reload = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reload.HoSoLink.Should().Be(link); + } + + [Fact] + public async Task UpdateDraft_OnChoDuyetPhase_ThrowsConflict_NoMutation() + { + // Phase != Nháp/Trả lại → ConflictException, HoSoLink KHÔNG bị đổi. + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + + var pe = BuildPe(PurchaseEvaluationPhase.ChoDuyet, hoSoLink: @"\\nas\HoSo\LOCKED", code: "PE-HSL-U5"); + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + var handler = new UpdatePurchaseEvaluationDraftCommandHandler(db, new FakeCurrentUser()); + var act = async () => await handler.Handle(UpdateCmd(pe.Id, @"\\nas\HoSo\HACK"), CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*Nháp hoặc Trả lại*"); + + var reload = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reload.HoSoLink.Should().Be(@"\\nas\HoSo\LOCKED"); + } +}