[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
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:
@ -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 đầu–cuố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)!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user