[CLAUDE] Tests: test-after HRM S65 — Department tree+cycle-guard, PE HoSoLink, HRM-perm seed (+23 -> 286)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m28s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-16 14:03:57 +07:00
parent 292d64d843
commit bcd619dbdb
3 changed files with 769 additions and 0 deletions

View File

@ -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<TestApplicationDbContext>();
// 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<TestApplicationDbContext>();
// 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<TestApplicationDbContext>();
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<TestApplicationDbContext>();
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<ConflictException>()
.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<ConflictException>()
.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<ConflictException>()
.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<TestApplicationDbContext>();
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<string>());
if (!isActive)
{
var tracked = await db.Users.FirstAsync(u => u.Id == user.Id);
tracked.IsActive = false;
await db.SaveChangesAsync(CancellationToken.None);
}
}
}
}

View File

@ -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<Role> 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<Role>, ILogger).
private static async Task InvokePrivateSeedAsync(IdentityFixture fix, string methodName)
{
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var rm = fix.Services.GetRequiredService<RoleManager<Role>>();
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<RoleManager<Role>>();
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<bool> 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<TestApplicationDbContext>();
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<TestApplicationDbContext>();
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<TestApplicationDbContext>();
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<TestApplicationDbContext>();
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<TestApplicationDbContext>();
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<TestApplicationDbContext>();
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");
}
}

View File

@ -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<string> Roles { get; init; } = System.Array.Empty<string>();
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<TestApplicationDbContext>();
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<TestApplicationDbContext>();
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<TestApplicationDbContext>();
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<TestApplicationDbContext>();
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<TestApplicationDbContext>();
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<ConflictException>()
.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");
}
}