[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
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:
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user