[CLAUDE] Contract: K1 SP-002 danh muc loai HD - ContractCatalogEntries (Mig 70) + seed 86 + CQRS + FE kind-5 x2 app + 8 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m31s

Gate: reviewer PASS-WITH-FLAGS 9f (sub-reviewer-gate-k1.md). Xu ly: F4
IncludeInactive mac-dinh-loai (spec K1.c) + test T5b; F7 comment 5-kind;
F1 OG-6 doi nhan soat-SAU @PAUSE-2 (seeder skip-if-exists giu sua-tay CRUD
qua restart - update-branch co y KHONG co); F3 authz mirror khuon Catalogs,
gioi-han khai: 4 policy ContractCatalog.* sinh ra nhung chua endpoint nao
tieu thu (display-layer khac API-layer, #82); F6 ten index IX_ giu EF-default
(NIT). Suite 598/0 (45D+553I). Mig 70 ap SQL Server that OK. Drift DELTA:
MenuKeys.All 55->56, policies 220->224, bang 96->97.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-31 15:45:50 +07:00
parent c0a96e54eb
commit 50e6d8c382
20 changed files with 8566 additions and 80 deletions

View File

@ -0,0 +1,469 @@
using System.Reflection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Master.Catalogs;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Persistence.Interceptors;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// [K1 — S164 2026-07-31] Master-data "Danh mục Hợp đồng" (SOL-CCM-SP-002: 86 dòng × 8 nhóm duyệt).
// Test-AFTER (feature mới, `docs/rules.md §7`) — code BE đã land trên đĩa (Mig 70, chưa commit).
//
// Code under test (test theo CODE, KHÔNG theo spec — spec chỉ dùng để đối chiếu):
// · `DbInitializer.cs:3163-3324` SeedContractCatalogAsync — 86 tuple, per-Code idempotent,
// dò existing bằng `IgnoreQueryFilters()` (:3293-3296) né #75/#76
// · `ContractCatalogEntryConfiguration.cs:27-29` — UNIQUE Code FILTERED `[IsDeleted] = 0`
// (#57) + index ApprovalGroup + query filter soft-delete
// · `ContractCatalogFeatures.cs` List/Get/Create/Update/Delete + 2 validator (1..8)
// · `MenuKeys.cs:32 + :173` const `ContractCatalog` **VÀO `All`** ⇒ Program.cs sinh
// 4 policy; `DbInitializer.cs:1776` menu row parent `Catalogs`
//
// ── 2 quyết định hạ tầng, cả hai là ĐIỀU KIỆN để phép đo có nghĩa ───────────────────────────
// (1) Fixture RIÊNG có `AuditingInterceptor` (KHÔNG dùng `SqliteDbFixture`). `TestApplicationDbContext`
// KHÔNG wire interceptor (`Common/SqliteDbFixture.cs:12-32`) ⇒ `Remove()` = xoá **CỨNG**
// (bài S155/S161). Ở đây thứ cần đo CHÍNH LÀ ý nghĩa của `Remove()`:
// · seeder skip-if-exists dựa vào row soft-deleted CÒN NẰM LẠI trong bảng;
// · rào UNIQUE filtered phân biệt row active ⟂ row đã xoá.
// Xoá cứng thì T2/T5 vẫn XANH kể cả khi `IgnoreQueryFilters()` bị gỡ hoặc index mất filter
// ⇒ đúng bẫy "assert rỗng nghĩa".
// (2) MỖI "lần boot"/"request" chạy trên MỘT DbContext MỚI (`NewDb()`), chung 1 connection —
// mô phỏng scoped-per-request của prod; seeder lần 2 buộc phải đọc lại ĐĨA chứ không ăn
// ké ChangeTracker của lần 1 (nếu ăn ké thì phép đo idempotent là giả).
//
// Vị trí file: `Application/` (cùng chỗ MasterCatalogFilteredUniqueTests / *SeedTests) chứ không
// `Services/` như lệnh gợi ý — `Services/` là chỗ của code-generator + workflow-service.
public class ContractCatalogTests
{
private static readonly DateTime FrozenNow = new(2026, 7, 31, 3, 0, 0, DateTimeKind.Utc);
// Phân bố CHUẨN theo transcribe SP-002 (nguồn: sub-invest-fable-b2-cum1.md:41).
private static readonly Dictionary<int, int> ExpectedByApprovalGroup = new()
{
[1] = 5, [2] = 10, [3] = 5, [4] = 23, [5] = 14, [6] = 15, [7] = 13, [8] = 1,
};
private static readonly Dictionary<string, int> ExpectedBySection = new()
{
["A1"] = 5, ["A2"] = 1, ["A3"] = 9, ["A4"] = 5,
["B1"] = 23, ["B2"] = 14, ["B3"] = 15,
["C1"] = 6, ["C2"] = 3, ["C3"] = 4, ["CEO"] = 1,
};
// ===================================================================
// Fixture: SQLite in-memory + AuditingInterceptor (xoá MỀM thật).
// ===================================================================
private sealed class CatalogFixture : IDisposable
{
private readonly SqliteConnection _conn;
public FixedDateTime Clock { get; } = new(FrozenNow);
public TestCurrentUser Actor { get; } = new(Guid.NewGuid(), "Quản trị danh mục", "admin@test.local", AppRoles.Admin);
public CatalogFixture()
{
_conn = new SqliteConnection("DataSource=:memory:");
_conn.Open();
using var boot = NewDb();
boot.Database.EnsureCreated();
}
/// Context MỚI cho mỗi "lần boot API" / mỗi request (xem ghi chú (2) đầu file).
public TestApplicationDbContext NewDb()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(_conn)
.EnableSensitiveDataLogging()
.AddInterceptors(new AuditingInterceptor(Actor, Clock))
.Options;
return new TestApplicationDbContext(options);
}
public void Dispose() => _conn.Dispose();
}
// `SeedContractCatalogAsync` là `private static` (ApplicationDbContext, ILogger) — gọi qua
// REFLECTION (khuôn S67), KHÔNG đổi visibility production, KHÔNG re-implement seeder trong test.
private static async Task InvokeSeedAsync(ApplicationDbContext db)
{
var mi = typeof(DbInitializer).GetMethod(
"SeedContractCatalogAsync", BindingFlags.NonPublic | BindingFlags.Static);
mi.Should().NotBeNull(
"DbInitializer.SeedContractCatalogAsync phải tồn tại (private static, (ApplicationDbContext, ILogger)) — đổi tên/signature thì cập nhật test");
await (Task)mi!.Invoke(null, new object[] { db, NullLogger.Instance })!;
}
private static ContractCatalogEntry NewEntry(string code, bool isDeleted = false) => new()
{
Id = Guid.NewGuid(),
Code = code,
TenVi = "Hạng mục " + code,
TenEn = "Item " + code,
GroupCode = "B1",
ApprovalGroup = 4,
SignerRole = "TP.PRO",
InitialsChain = "TBP.PRO → NV.PRO",
IsActive = true,
SortOrder = 1,
IsDeleted = isDeleted,
};
private static CreateContractCatalogEntryCommand CreateCmd(int approvalGroup = 4, string code = "Z-01") =>
new(code, "Hạng mục thử", "Test item", "B1", approvalGroup, "TP.PRO", "TBP.PRO → NV.PRO", null, true, 7);
private static UpdateContractCatalogEntryCommand UpdateCmd(Guid id, int approvalGroup = 4, string code = "Z-01") =>
new(id, code, "Hạng mục thử", "Test item", "B1", approvalGroup, "TP.PRO", "TBP.PRO → NV.PRO", null, true, 7);
// =====================================================================================
// T1 — Seed idempotent: 2 lần boot ⇒ vẫn ĐÚNG 86 row, không nhân đôi.
// (acceptance K1.f: "Restart API lần 2 → vẫn 86", khuôn nghiệm-thu-bằng-restart S91)
// =====================================================================================
[Fact]
public async Task Seed_RunTwice_Keeps86Rows_NoDuplicateCode()
{
using var f = new CatalogFixture();
await using (var db = f.NewDb()) { await InvokeSeedAsync(db); } // boot 1
await using (var check1 = f.NewDb())
{
(await check1.ContractCatalogEntries.CountAsync()).Should().Be(86,
"boot 1 phải nạp đủ 86 dòng SP-002 — nếu 0 thì mọi assert idempotent dưới là rỗng nghĩa");
}
await using (var db = f.NewDb()) { await InvokeSeedAsync(db); } // boot 2 (= restart API)
await using var check = f.NewDb();
(await check.ContractCatalogEntries.CountAsync()).Should().Be(86,
"seeder skip-if-Code-exists ⇒ lần boot thứ 2 KHÔNG thêm row nào");
(await check.ContractCatalogEntries.IgnoreQueryFilters().CountAsync()).Should().Be(86,
"kể cả tính row đã xoá mềm cũng chỉ 86 — không có bản sao ẩn nào sinh ra");
var duplicated = await check.ContractCatalogEntries.IgnoreQueryFilters()
.GroupBy(x => x.Code).Where(g => g.Count() > 1).Select(g => g.Key).ToListAsync();
duplicated.Should().BeEmpty("mỗi mã danh mục chỉ được tồn tại 1 lần sau N lần boot");
}
// =====================================================================================
// T1b — Chống HỒI SINH (#75/#76): admin xoá mềm 1 dòng ⇒ lần boot sau KHÔNG tạo lại.
// Đây là phép đo cho `IgnoreQueryFilters()` ở DbInitializer.cs:3294 — hit
// `IgnoreQueryFilters` ĐẦU TIÊN trong src/Backend, cố ý làm khác khuôn :2970.
// Nếu bỏ `IgnoreQueryFilters()`: row soft-deleted vô hình ⇒ seeder Add lại ⇒
// active vẫn 86 (TRÔNG NHƯ ĐÚNG!) nhưng tổng thành 87 ⇒ 2 assert dưới bắt được.
// =====================================================================================
[Fact]
public async Task Seed_AfterSoftDeleteOfOneCode_DoesNotResurrectIt()
{
using var f = new CatalogFixture();
await using (var db = f.NewDb()) { await InvokeSeedAsync(db); }
// Xoá MỀM đúng đường prod đi (DeleteContractCatalogEntryHandler → Remove + interceptor).
await using (var db = f.NewDb())
{
var row = await db.ContractCatalogEntries.SingleAsync(x => x.Code == "A1-01");
db.ContractCatalogEntries.Remove(row);
await db.SaveChangesAsync();
}
// Sanity CHỐNG-VACUOUS: phải là xoá MỀM. Xoá cứng thì phép đo dưới đo nhầm cơ chế.
await using (var probe = f.NewDb())
{
var raw = await probe.ContractCatalogEntries.IgnoreQueryFilters()
.SingleAsync(x => x.Code == "A1-01");
raw.IsDeleted.Should().BeTrue("AuditingInterceptor phải biến Remove() thành xoá mềm");
}
await using (var db = f.NewDb()) { await InvokeSeedAsync(db); } // boot kế tiếp
await using var check = f.NewDb();
(await check.ContractCatalogEntries.CountAsync()).Should().Be(85,
"dòng admin đã xoá phải Ở LẠI trạng thái đã xoá — seeder KHÔNG được kéo nó về");
(await check.ContractCatalogEntries.IgnoreQueryFilters().CountAsync()).Should().Be(86,
"tổng row vẫn 86 ⇒ seeder KHÔNG chèn thêm bản sao active của A1-01 (nếu thiếu IgnoreQueryFilters ⇒ 87)");
(await check.ContractCatalogEntries.IgnoreQueryFilters().CountAsync(x => x.Code == "A1-01"))
.Should().Be(1, "chỉ đúng 1 row mang mã A1-01, và nó đang ở trạng thái đã xoá");
}
// =====================================================================================
// T2 — Seed đủ 8 nhóm duyệt: phân bố 5/10/5/23/14/15/13/1 + phân bố theo section
// + tên VN/EN đủ 86/86 + denorm vai-ký nhất quán trong từng nhóm.
// =====================================================================================
[Fact]
public async Task Seed_ApprovalGroupAndSectionDistribution_MatchesSp002()
{
using var f = new CatalogFixture();
await using (var db = f.NewDb()) { await InvokeSeedAsync(db); }
await using var check = f.NewDb();
var rows = await check.ContractCatalogEntries.AsNoTracking().ToListAsync();
rows.Should().HaveCount(86);
rows.GroupBy(r => r.ApprovalGroup).ToDictionary(g => g.Key, g => g.Count())
.Should().BeEquivalentTo(ExpectedByApprovalGroup,
"phân bố nhóm duyệt N1..N8 = 5/10/5/23/14/15/13/1 (acceptance K1.f) — sai 1 dòng bị gán nhầm nhóm là phiếu KHKK chạy nhầm quy trình");
rows.GroupBy(r => r.GroupCode).ToDictionary(g => g.Key, g => g.Count())
.Should().BeEquivalentTo(ExpectedBySection,
"phân bố theo section A1..CEO — bắt trường hợp tổng vẫn 86 nhưng 1 dòng bị chuyển section");
rows.Where(r => string.IsNullOrWhiteSpace(r.TenVi)).Should().BeEmpty("tên VN đủ 86/86");
rows.Where(r => string.IsNullOrWhiteSpace(r.TenEn)).Should().BeEmpty("tên EN đủ 86/86 (acceptance K1.f)");
rows.Where(r => string.IsNullOrWhiteSpace(r.SignerRole)).Should().BeEmpty("vai ký chính bắt buộc (in trên khối ký GĐ4)");
rows.Where(r => r.ApprovalGroup is < 1 or > 8).Should().BeEmpty("ApprovalGroup chỉ được nằm trong 1..8");
rows.Should().OnlyContain(r => r.IsActive, "86 dòng seed đều đang hiệu lực");
rows.Should().OnlyContain(r => r.NduqNote == null, "[OG-3] NĐUQ per-dòng chưa có trong ảnh gốc ⇒ seed null, nhập tay sau");
// Denorm: SignerRole/InitialsChain chép theo NHÓM vào từng row ⇒ trong 1 nhóm phải ĐỒNG NHẤT.
// Bắt lỗi gõ tay lệch 1 ký tự ở 1 dòng (thứ mà đếm-số-lượng không bao giờ thấy).
foreach (var g in rows.GroupBy(r => r.ApprovalGroup).OrderBy(g => g.Key))
{
g.Select(r => r.SignerRole).Distinct().Should().HaveCount(1,
$"nhóm N{g.Key} phải dùng CÙNG 1 chuỗi vai ký chính cho cả {g.Count()} dòng");
g.Select(r => r.InitialsChain).Distinct().Should().HaveCount(1,
$"nhóm N{g.Key} phải dùng CÙNG 1 chuỗi ký nháy cho cả {g.Count()} dòng");
}
// Neo 2 mốc đầucuối bảng gốc (chống trường hợp phân bố đúng mà nội dung trôi).
rows.Single(r => r.Code == "CEO-01").ApprovalGroup.Should().Be(8);
rows.Single(r => r.Code == "CEO-01").SignerRole.Should().Be("CEO");
rows.Single(r => r.Code == "A1-01").ApprovalGroup.Should().Be(1);
}
// =====================================================================================
// T3 — UNIQUE Code FILTERED `[IsDeleted] = 0` (gotcha #57) đo ở TẦNG DB, 2 CHIỀU:
// (a) 2 row ACTIVE cùng Code → DbUpdateException (index có tồn tại thật)
// (b) Code trùng row ĐÃ XOÁ MỀM → OK (index có mệnh đề WHERE thật)
// Thiếu chiều (a) ⇒ test xanh cả khi index chưa từng được tạo.
// Thiếu chiều (b) ⇒ test xanh cả khi index là UNIQUE trần (bug #57).
// =====================================================================================
[Fact]
public async Task UniqueCode_IsFilteredByIsDeleted_BlocksActiveDup_AllowsReuseAfterSoftDelete()
{
using var f = new CatalogFixture();
await using (var db = f.NewDb())
{
db.ContractCatalogEntries.Add(NewEntry("X-01"));
await db.SaveChangesAsync();
}
// (a) trùng Code trên row ĐANG ACTIVE → DB phải chặn.
await using (var db = f.NewDb())
{
db.ContractCatalogEntries.Add(NewEntry("X-01"));
var act = async () => await db.SaveChangesAsync();
await act.Should().ThrowAsync<DbUpdateException>(
"UNIQUE index trên Code phải chặn 2 dòng danh mục ACTIVE cùng mã");
}
// Xoá mềm dòng đang chiếm slot.
await using (var db = f.NewDb())
{
var row = await db.ContractCatalogEntries.SingleAsync(x => x.Code == "X-01");
db.ContractCatalogEntries.Remove(row);
await db.SaveChangesAsync();
}
// (b) slot đã xoá mềm ⇒ tái dùng được mã.
await using (var db = f.NewDb())
{
db.ContractCatalogEntries.Add(NewEntry("X-01"));
var act = async () => await db.SaveChangesAsync();
await act.Should().NotThrowAsync(
"index có mệnh đề WHERE [IsDeleted] = 0 ⇒ row đã xoá mềm KHÔNG chiếm slot mã nữa (#57)");
}
await using var check = f.NewDb();
(await check.ContractCatalogEntries.CountAsync(x => x.Code == "X-01")).Should().Be(1,
"chỉ 1 dòng active mang mã X-01");
(await check.ContractCatalogEntries.IgnoreQueryFilters().CountAsync(x => x.Code == "X-01")).Should().Be(2,
"bản đã xoá giữ lại cho vết audit + bản active mới");
}
// =====================================================================================
// T4 — Validator: ApprovalGroup ngoài 1..8 ⇒ FAIL, trong 1..8 ⇒ PASS (cả Create lẫn Update).
// Assert THÊM tên field lỗi: chống trường hợp "fail nhưng vì field khác sai".
// =====================================================================================
[Fact]
public void Validator_ApprovalGroupOutOfRange_Fails_InRange_Passes()
{
var createV = new CreateContractCatalogEntryValidator();
var updateV = new UpdateContractCatalogEntryValidator();
var id = Guid.NewGuid();
foreach (var bad in new[] { 0, 9, -1, 99 })
{
var rc = createV.Validate(CreateCmd(bad));
rc.IsValid.Should().BeFalse($"Create với ApprovalGroup={bad} nằm ngoài 1..8 (N1..N8)");
rc.Errors.Select(e => e.PropertyName).Should()
.Contain(nameof(CreateContractCatalogEntryCommand.ApprovalGroup),
$"lỗi phải rơi ĐÚNG field ApprovalGroup (giá trị {bad}), không phải field khác");
var ru = updateV.Validate(UpdateCmd(id, bad));
ru.IsValid.Should().BeFalse($"Update với ApprovalGroup={bad} nằm ngoài 1..8");
ru.Errors.Select(e => e.PropertyName).Should()
.Contain(nameof(UpdateContractCatalogEntryCommand.ApprovalGroup));
}
foreach (var ok in new[] { 1, 4, 8 })
{
createV.Validate(CreateCmd(ok)).IsValid.Should().BeTrue(
$"Create với ApprovalGroup={ok} hợp lệ — biên 1 và 8 phải LỌT (InclusiveBetween)");
updateV.Validate(UpdateCmd(id, ok)).IsValid.Should().BeTrue(
$"Update với ApprovalGroup={ok} hợp lệ");
}
}
// =====================================================================================
// T5 — Vòng đời qua HANDLER thật: Create → List thấy → Create trùng mã = Conflict →
// Delete = XOÁ MỀM (List/Get ẩn, row còn trong bảng, có DeletedAt/DeletedBy) →
// tạo lại CÙNG mã thành công (dup-check chạy trên active-set, khớp index filtered).
// =====================================================================================
[Fact]
public async Task Create_Then_Delete_IsSoft_HiddenFromListAndGet_RowRemains_CodeReusable()
{
using var f = new CatalogFixture();
var ct = CancellationToken.None;
Guid id;
await using (var db = f.NewDb())
id = await new CreateContractCatalogEntryHandler(db).Handle(CreateCmd(), ct);
await using (var db = f.NewDb())
{
var list = await new ListContractCatalogHandler(db).Handle(new ListContractCatalogQuery(), ct);
list.Should().ContainSingle(x => x.Id == id, "dòng vừa tạo phải xuất hiện trong danh sách");
}
// Trùng mã trên active-set → 409.
await using (var db = f.NewDb())
{
var act = async () => await new CreateContractCatalogEntryHandler(db).Handle(CreateCmd(), ct);
await act.Should().ThrowAsync<ConflictException>("mã danh mục đang được 1 dòng active dùng");
}
await using (var db = f.NewDb())
await new DeleteContractCatalogEntryHandler(db).Handle(new DeleteContractCatalogEntryCommand(id), ct);
await using (var db = f.NewDb())
{
var list = await new ListContractCatalogHandler(db).Handle(new ListContractCatalogQuery(), ct);
list.Should().NotContain(x => x.Id == id, "query filter phải ẩn dòng đã xoá khỏi danh sách");
var get = async () => await new GetContractCatalogEntryHandler(db)
.Handle(new GetContractCatalogEntryQuery(id), ct);
await get.Should().ThrowAsync<NotFoundException>("Get theo id của dòng đã xoá ⇒ 404");
var raw = await db.ContractCatalogEntries.IgnoreQueryFilters().SingleAsync(x => x.Id == id);
raw.IsDeleted.Should().BeTrue("xoá MỀM — row phải còn lại trong bảng (audit + K2 Line denorm tên)");
raw.DeletedAt.Should().Be(FrozenNow, "AuditingInterceptor đóng dấu thời điểm xoá");
raw.DeletedBy.Should().Be(f.Actor.UserId, "AuditingInterceptor đóng dấu người xoá");
}
// Tạo lại CÙNG mã: dup-check của handler chạy trên active-set ⇒ phải cho qua.
await using (var db = f.NewDb())
{
var newId = await new CreateContractCatalogEntryHandler(db).Handle(CreateCmd(), ct);
newId.Should().NotBe(id, "là dòng MỚI, không phải hồi sinh dòng cũ");
}
await using var check = f.NewDb();
(await check.ContractCatalogEntries.CountAsync()).Should().Be(1);
(await check.ContractCatalogEntries.IgnoreQueryFilters().CountAsync()).Should().Be(2);
}
// =====================================================================================
// T5b — [gate-K1 F4 @S165] List MẶC ĐỊNH loại dòng ngừng hiệu lực (spec K1.c
// `IncludeInactive?` mặc-định-loại); includeInactive=true mới thấy cả hai.
// Chốt ngữ nghĩa TRƯỚC khi picker K2 xây trên List này.
// =====================================================================================
[Fact]
public async Task List_DefaultExcludesInactive_IncludeInactiveShowsAll()
{
using var f = new CatalogFixture();
var ct = CancellationToken.None;
await using (var db = f.NewDb())
{
var off = NewEntry("Z-OFF");
off.IsActive = false;
db.ContractCatalogEntries.AddRange(NewEntry("Z-ON"), off);
await db.SaveChangesAsync(ct);
}
await using (var db = f.NewDb())
{
var defaults = await new ListContractCatalogHandler(db).Handle(new ListContractCatalogQuery(), ct);
defaults.Select(x => x.Code).Should().Contain("Z-ON")
.And.NotContain("Z-OFF", "mặc định phải LOẠI dòng IsActive=false (spec K1.c)");
var all = await new ListContractCatalogHandler(db)
.Handle(new ListContractCatalogQuery(IncludeInactive: true), ct);
all.Select(x => x.Code).Should().Contain(["Z-ON", "Z-OFF"]);
}
}
// =====================================================================================
// T6 — Menu key + grant seed. 3 mắt xích PHẢI khớp nhau, hỏng 1 là hỏng lúc BOOT:
// (i) `ContractCatalog` ∈ `MenuKeys.All` ⇒ Program.cs mới sinh 4 policy
// ContractCatalog.{Read,Create,Update,Delete}
// (ii) `SeedMenuTreeAsync` phải có row MenuItem cho key đó (FK Permission.MenuKey →
// MenuItem.Key, Cascade). Key ∈ All mà thiếu row menu ⇒ SeedAdminPermissionsAsync
// chèn Permission → nổ FK ngay lần boot đầu (SQLite Error 19 / FK prod).
// (iii) Admin được grant đủ 4 cờ CRUD qua loop `MenuKeys.All` (:2058).
// Bản thân việc CHẠY 2 seeder này = smoke-test cho invariant (ii) trên TOÀN BỘ `All`.
// =====================================================================================
[Fact]
public async Task MenuKey_ContractCatalog_InAll_SeededUnderCatalogs_AndGrantedToAdmin()
{
MenuKeys.All.Should().Contain(MenuKeys.ContractCatalog,
"Program.cs chỉ sinh policy cho key ∈ MenuKeys.All — key ngoài All thì [Authorize(Policy=...)] tương ứng sẽ 500 lúc chạy");
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var rm = fix.Services.GetRequiredService<RoleManager<Role>>();
var adminRole = new Role { Id = Guid.NewGuid(), Name = AppRoles.Admin };
(await rm.CreateAsync(adminRole)).Succeeded.Should().BeTrue();
await InvokePrivateSeedAsync(db, "SeedMenuTreeAsync");
await InvokePrivateSeedAsync(db, "SeedAdminPermissionsAsync", rm);
// (ii) row menu đúng chỗ: leaf dưới nhóm "Danh mục chi tiết".
var item = await db.MenuItems.AsNoTracking()
.FirstOrDefaultAsync(m => m.Key == MenuKeys.ContractCatalog);
item.Should().NotBeNull("SeedMenuTreeAsync phải tạo row menu cho key mới");
item!.ParentKey.Should().Be(MenuKeys.Catalogs, "leaf nằm dưới nhóm Danh mục chi tiết");
// Invariant TOÀN BỘ All (nguồn của lỗi FK lúc boot).
var menuKeysInDb = await db.MenuItems.AsNoTracking().Select(m => m.Key).ToListAsync();
MenuKeys.All.Except(menuKeysInDb).Should().BeEmpty(
"mọi key trong MenuKeys.All phải có row MenuItem — thiếu row là Permission FK nổ ngay lần boot đầu");
// (iii) Admin có đủ 4 cờ.
var perm = await db.Permissions.AsNoTracking()
.FirstOrDefaultAsync(p => p.RoleId == adminRole.Id && p.MenuKey == MenuKeys.ContractCatalog);
perm.Should().NotBeNull("SeedAdminPermissionsAsync loop qua MenuKeys.All phải cấp cho Admin");
perm!.CanRead.Should().BeTrue();
perm.CanCreate.Should().BeTrue();
perm.CanUpdate.Should().BeTrue();
perm.CanDelete.Should().BeTrue();
}
// Reflection cho seeder private static: (db, logger) hoặc (db, roleManager, logger).
private static async Task InvokePrivateSeedAsync(
TestApplicationDbContext db, string methodName, RoleManager<Role>? rm = null)
{
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 args = rm is null
? new object[] { db, NullLogger.Instance }
: new object[] { db, rm, NullLogger.Instance };
await (Task)mi!.Invoke(null, args)!;
}
}