TEST-K4A: DONE — 8/8 PASS (6 gốc + T7 finding-D + 1 gate-K4b MAJOR-A) · Infra 569→577 · Domain 45 · tổng 622 · 0 hồi quy · 0 prod-code touched

# Lane test K4a — test-after 49 row menu KHKK 8-nhóm (S166, 2026-08-01)

Agent: test-specialist. Ghi-từng-mục TRONG LÚC LÀM (luật sống-còn bài K3 engine-exit).
Mục nào chưa chạy = ghi tiêu-đề + "CHƯA ĐO", KHÔNG điền số dự-đoán.

## §0 — Trạng thái khởi động

- [x] §0.1 ĐÃ đọc khuôn. Chốt harness: seeder là `private static` → REFLECTION.
  - `SeedMenuTreeAsync(ApplicationDbContext, ILogger)` — 2 tham số.
  - `SeedAllRolesReviewReadPermissionsAsync(ApplicationDbContext, RoleManager<Role>, ILogger)` — 3 tham số (site-2).
  - Khuôn invoke đã có sẵn ở `ContractCatalogTests.cs:459` (`InvokePrivateSeedAsync`, rm nullable) →
    dùng lại HÌNH, không chế fixture mới. Fixture = `IdentityFixture` (cần RoleManager cho site-2).
- [x] §0.2 ĐÃ đọc BE đã land (git diff, chưa commit):
  - `MenuKeys.cs:72-84` — `KhkkGroupNumbers=[2..8]` · `KhkkLeafActions=[WfView,List,Create,Pending,Approved,Deleted]`
    · `KhkkGroupMenuKeys` = SelectMany(group → 6 leaf) · spread `.. KhkkGroupMenuKeys` vào `All:244`.
  - `DbInitializer.cs:2115-2157` — vòng seed 49 row menu (parent `KeHoachKyKet`, Order nhóm = n,
    leaf Order 1..6) + `:2235` labelBackfill `Khkk_G1` + `:2442-2446` site-2 `KhkkKeys().Concat(KhkkGroupMenuKeys)`.
  - Đếm BY-SOURCE `All` = 113 (tự cộng tay từng dòng initializer). **Chưa phải số ĐO** — sẽ đo runtime ở T1.
- [x] §0.3 CHỒNG LẤN ĐÃ CÓ (không viết lại): `ContractCatalogTests.cs:445` T6 K1 assert
  `MenuKeys.All.Except(menuKeysInDb).Should().BeEmpty()` sau `SeedMenuTreeAsync` ⇒ invariant
  "49 key mới ĐỀU có menu-row" ĐÃ được test cũ phủ tự động (nó là phép thử của claim by-construction).
  Lane K4a KHÔNG nhân bản; thay vào đó đo cái test cũ KHÔNG đo: hình bộ sinh · đếm 56 row họ Khkk ·
  idempotent ×2 · labelBackfill · grant site-2.
- [x] §0.4 **Baseline Infra ĐO TƯƠI 2026-08-01 (trước khi thêm file): 569 PASS / 0 FAIL** (42 s).
  Đo trên cây có BE K4a đã land ⇒ test T6-K1 cũ (`All.Except(menuKeysInDb)`) XANH với 49 key mới
  = bằng chứng độc lập rằng vòng seed 49 row khớp đúng bộ key trong `All`.

## §1..§4 — File test đã LAND: `tests/SolutionErp.Infrastructure.Tests/Application/KhkkGroupMenuSeedTests.cs` (6 test)

Harness: reflection `private static` + `IdentityFixture` (không fixture mới). Không đụng prod code.

| # | Test | Đo cái gì | Chống vacuous bằng |
|---|---|---|---|
| T1 | `KhkkGroupMenuKeys_Is49Keys_GroupImmediatelyFollowedBySixLeaves` | 49 key · `Equal` với **mảng 49 chuỗi VIẾT TAY** (không gọi lại factory ⇒ không tautology) · liền-kề `Khkk_G{n}` ngay trước `Khkk_G{n}_WfView` · pin 6 action | kỳ vọng là literal, sai 1 ký tự là đỏ |
| T2 | `MenuKeys_All_ContainsThe49_NoDuplicate_AndKeepsGroup1Outside` | All ⊇ 49 · `OnlyHaveUniqueItems` · **`HaveCount(113)` = số ĐO runtime** · `KhkkGroupNumbers == [2..8]` · 7 key nhóm 1 KHÔNG nằm trong All | vế cuối = guard thật: thêm số 1 vào bộ sinh ⇒ `tree.Add("Khkk_G1")` lần 2 ⇒ trùng PK lúc boot |
| T3 | `SeedMenuTree_RunTwice_Has56KhkkRows_NoDuplication_AndCorrectTreeShape` | seed ×2: **56 row họ Khkk** (7 nhóm-1 + 49) · lần 2 danh sách key Y NGUYÊN · **tổng bảng MenuItems bất động** · 8 group parent=`KeHoachKyKet` Order=n · mỗi group đúng 6 con · 8 nhãn nhóm phân biệt | đếm bằng Ordinal in-memory (không phụ thuộc collation LIKE); đã kiểm KHÔNG có `HasData` cho MenuItems ⇒ 56 row đến TỪ seeder, không phải EF seed |
| T4 | `SeedMenuTree_NewGroupLeaves_MirrorGroup1_LabelIconOrder` | 42 leaf mới khớp **nhãn + icon + Order** của leaf nhóm 1 tương ứng (đo DB↔DB, không bảng hardcode) | chốt trước "6 nhãn nhóm 1 PHÂN BIỆT" — nếu chúng trùng nhau, phép so mirror đúng ở mọi thế giới |
| T5 | `SeedMenuTree_LabelBackfill_HealsGroup1Label_WithoutAddingRows_ControlKeyStaysDirty` | **MA TRẬN cùng-ruột-khác-1-biến**: bôi hỏng nhãn CẢ `Khkk_G1` (ca thật: về chuỗi skeleton S159) LẪN `Khkk_G2` trong cùng 1 lần → seed lại → G1 **tự lành**, G2 **giữ chữ hỏng**; tổng row bất động | 2 kết quả TRÁI NGƯỢC trong 1 lần chạy; khác biệt duy nhất = có/không entry `labelBackfill` ⇒ chứng đúng cơ chế backfill, không phải upsert "tiện tay" ghi Label (gotcha #11) |
| T6 | `SeedAllRolesReviewRead_GrantsReadOnNew49KhkkKeys_ReadOnly_WithPeContrast` | site-2 phát CanRead cho cả 49 key (nêu tên `Khkk_G2_List`) · C/U/D=false · 7 key nhóm 1 GIỮ quyền (`.Concat` mở rộng chứ không thay thế) | **ĐỐI CHỨNG CÙNG LƯỢT**: cùng role đó `Pe_DuyetNcc_List` có CanCreate=true ⇒ chuỗi "false" trên Khkk không phải vì seeder no-op. Chạy `SeedMenuTreeAsync` trước = smoke FK cho TOÀN grant-set |

Đường vào duy nhất của 49 key ở site-2 = `KhkkKeys().Concat(MenuKeys.KhkkGroupMenuKeys)` (K4a) —
`All.Where(StartsWith "Pe_")` không bắt `Khkk_*` ⇒ gỡ `.Concat` là T6 ĐỎ.

## §5 — Chạy filtered — ĐÃ ĐO

`dotnet test tests/SolutionErp.Infrastructure.Tests --filter "FullyQualifiedName~KhkkGroupMenuSeed" --nologo`
→ **Failed: 0, Passed: 6, Total: 6** (3 s).
Ghi chú số: `MenuKeys.All` runtime = **113** ĐÚNG như đếm by-source của lane BE (assert xanh) ⇒
`Policies` DERIVED = 113 × 4 = **452**.

## §6 — Full suite — ĐÃ ĐO

- **Infra: Failed 0, Passed 575, Total 575** (30 s) — baseline 569 + 6 = 575 ⇒ **0 hồi quy**.
- **Domain: Failed 0, Passed 45** (311 ms) — đo tươi, không lấy từ trí nhớ.
- **Tổng suite = 45 + 575 = 620.**

Caveat trung thực: giữa lượt làm có commit `7a903cf` (lane F-1, FE + `ContractSigningPlanFeatures.cs`)
land vào repo. Đã kiểm: `git diff --stat -- src/` vẫn đúng 42/79 dòng của riêng K4a như lúc mở lượt
⇒ byte nguồn K4a KHÔNG đổi trong lúc đo; file lane F-1 đã nằm sẵn trên đĩa TRƯỚC lần chạy full
(nó là ` M` từ đầu, commit chỉ chuyển trạng thái index) ⇒ con số 575 đo đúng trạng thái đĩa hiện tại.

## §7 — Rào đã giữ

- KHÔNG chạm production code: `git status` cho thấy dưới `tests/` chỉ có **1 file MỚI**
  `KhkkGroupMenuSeedTests.cs`; 2 file `src/` đang ` M` là diff của lane BE K4a (có từ trước lượt này).
- KHÔNG đụng `ContractSigningPlan*` / `KhkkWorkflowPanel` / `khkk.ts`.
- KHÔNG commit, KHÔNG push.
- KHÔNG sửa `SolutionErp.slnx` (project SDK-style tự glob `.cs`; test đã build + chạy).

## §8 — Quan sát gửi lead (KHÔNG phải bug, không tự sửa)

1. **Chồng lấn có lợi:** test T6 lane K1 (`ContractCatalogTests.cs:445`) nay là phép thử tự động cho
   claim "49 key ∈ All đều có menu-row" — nó XANH ở baseline 569 ⇒ claim by-construction của K4a đã
   có chứng nhân độc lập, lane K4a không cần lặp lại.
2. **Nhãn G2..G8 KHÔNG tự lành** (đo ở T5, đối chứng): nếu owner đổi chữ 7 nhãn nhóm sau khi prod đã
   seed thì sửa trong `khkkGroupLabels` là VÔ TÁC DỤNG — phải thêm entry `labelBackfill`. Comment
   BE `:2233-2234` đã khai đúng điều này; T5 nay khoá nó thành bất-biến có răng.
3. **Số canonical cần đổi kèm K4a:** `MenuKeys.All` ĐO runtime = 113 ⇒ `docs/STATUS.md` row
   `Menu keys` = 113 · `Policies` = 452. (T2 sẽ ĐỎ nếu 2 số này drift khỏi mã.)

## §D — RESUME gate K4a, finding D (MAJOR): bất-biến "revoker KHÔNG đụng `Khkk_*`"

Lệnh lead: +1 Fact `Revoke_DoesNotTouch_KhkkGroups`. Lý do: ca ĐÃ VỠ THẬT trên prod
(Run #423 — 447/494 row rơi, 11/13 role về 0; gotcha #84) mà hiện 0 test ⇒ thêm 1 chữ vào
bộ lọc revoke thì suite vẫn xanh.

- [x] §D.1 Đọc PREDICATE THẬT từ mã (`DbInitializer.cs:2554-2568`), không theo prompt:
  `RoleId != adminRoleId && (MenuKey.StartsWith("Hrm") || MenuKey.StartsWith("Off")
  || MenuKey == Personal) && (bất kỳ cờ nào đang true)` → set cả 4 cờ = false.
  🔴 **Bẫy vacuous-pass phát hiện khi đọc mã:** `:2546-2551` — **Admin role chưa seed ⇒ revoker
  RETURN SỚM, no-op**. Test không seed Admin sẽ XANH vì revoker CHƯA TỪNG CHẠY, chứ không
  phải vì `Khkk_*` được tha. Bắt buộc seed Admin + có **control dương**.
- [x] §D.2 Chọn chỗ đặt: `KhkkGroupMenuSeedTests.cs` (file của lane K4a) — đã có sẵn mảng
  `Expected49` + 7 key nhóm 1 + harness seeder; đặt ở `AdminOnlyModulesRevokeTests.cs` sẽ phải
  chép lại danh sách 49 key (nguồn thứ 2 = nguy cơ lệch). Vẫn MIRROR khuôn 2-vế của file đó.
- [x] §D.3 ĐÃ viết `T7 Revoke_DoesNotTouch_KhkkGroups` (cuối `KhkkGroupMenuSeedTests.cs`).
  Kịch bản 1 lượt, 3 chiều phân biệt:
  1. seed menu THẬT + site-2 grant THẬT cho 3 role (Admin + Drafter + DeptManager);
  2. cấp tay 2 key **trong** tập ẩn (`Off_DanhBa`, `Personal`) cho cả 3 role = **CONTROL DƯƠNG**;
  3. tick thêm `CanCreate` cho `Khkk_G5_Create` (mô phỏng admin bật cờ ghi trên ma trận);
  4. chạy `RevokeTemporarilyHiddenModulesAsync`;
  5. **Vế 1** (control dương): 2 key kia của 2 role non-Admin về **false cả 4 cờ** ⇒ chứng
     revoker CÓ CHẠY (nếu đỏ = phép đo hỏng, không phải Khkk hỏng);
     **Admin giữ true** ⇒ chứng vế `RoleId != adminRoleId` còn sống;
     **Vế 2**: 56 key Khkk × 2 role = **112 row sống sót**, `CanRead` true, row KHÔNG bị xoá,
     cờ ghi đã tick vẫn true.
  Đếm bằng ĐƠN VỊ của sự cố thật (row sống sót) để thông điệp lỗi cùng ngôn ngữ Run #423.
- [x] §D.4 **CHỨNG T7 CÓ RĂNG bằng PROBE VI-PHÂN** (không được chạm prod ⇒ không fault-inject
  vào `DbInitializer`): file tạm `ZzProbeTeethTemp.cs` dựng lại ĐÚNG hình câu truy vấn revoke
  (`:2554-2568`) + thêm **đúng 1 vế** `|| MenuKey.StartsWith("Khkk")` rồi đo trên DB thật.
  Kết quả ĐO: **trước = 112 row Khkk CanRead=true · sau bộ-lọc-bị-nới = 0** ⇒ đúng tập row mà
  T7 assert sẽ rơi hết ở thế giới hỏng ⇒ T7 phân biệt được 2 thế giới, KHÔNG phải assert đúng
  ở mọi thế giới. Probe chạy 1/1 PASS rồi **XOÁ**; verify 2 chiều: `git status -- tests/` chỉ
  còn 1 file `??` (KhkkGroupMenuSeedTests.cs) và suite = 569+**7** (không phải +8) ⇒ 0 dư lượng.

## §9 — SỐ CHỐT (đo tươi, lần cuối, sau khi đã xoá probe)

- Filtered `~KhkkGroupMenuSeed`: **7/7 PASS**.
- **Infra: 576 PASS / 0 FAIL** (baseline 569 + 7) · **Domain: 45 PASS / 0 FAIL**.
- **Tổng suite = 621.**

## §MAJOR-A — RESUME 2: nợ gate-K4b, lọc nhóm màn "Đã xoá" (`GET /contract-signing-plans/deleted`)

- [x] **Chọn file = `ContractSigningPlanCrudTests.cs`** (KHÔNG phải file K4a của tôi): nó có sẵn
  `KhkkFixture` **có `AuditingInterceptor`** — điều kiện SỐNG CÒN vì màn "Đã xoá" đọc row
  `IsDeleted=1`; fixture thường xoá CỨNG ⇒ màn rỗng ⇒ test đo nhầm cơ chế (bài S155/S161-W2).
  Kèm sẵn `SeedPeAsync` + `CreateAsync` + `DeleteAsync`. Dựng lại ở file K4a = nhân bản ~120 LOC.
- [x] **+1 Fact `ListDeletedContractSigningPlans_FiltersByApprovalGroup_ServerSide`** (test thứ 8
  của lớp) + mở rộng helper `CreateAsync` thêm optional `approvalGroup` + helper mới
  `ListDeletedAsync`. Cùng 1 phiếu PE, 2 nhóm khác nhau (1 và 3) — hợp lệ vì rào "1 PE = 1 kế
  hoạch sống" khoá theo CẶP (PeId, ApprovalGroup) `:401-404`; workflow seed Code `QT-KHKK-V2`
  nằm NGOÀI họ `KHKK-N{n}` nên rào (v) `:420-424` cho qua.
- [x] 5 vế đo (mỗi vế đều assert **`Total`**, không chỉ `Items` — `Total` = dấu vân tay lọc-SERVER
  vì `CountAsync` chạy sau mọi `Where`; lọc client-side cho `Items` y hệt mà `Total` = cả thế giới):
  (1) không-param ⇒ 2/Total 2 · (2) group=1 ⇒ 1/Total 1 đúng phiếu G1 · (3) group=3 ⇒ đúng phiếu
  G3 (chứng không phải "luôn trả cái đầu") · (4) group=7 ⇒ RỖNG (lọc không khớp ≠ bỏ qua lọc) ·
  (5) **nhánh ADMIN** ⇒ vẫn lọc đúng (vế lọc nằm NGOÀI khối IDOR `:884-893`; chỉ đo 1 vai thì
  đẩy vế lọc vào trong `if (!admin)` vẫn xanh). +2 sanity trước phép đo chính (2 row THẬT sự
  `IsDeleted` ∧ THẬT sự khác nhóm).
- [x] **CHỨNG CÓ RĂNG — RED→GREEN 2 CHIỀU, ĐO THẬT, KHÔNG CHẠM PROD:**
  thế-giới-hỏng ("handler bỏ qua param") **tương đương chính xác** với `Handle(ApprovalGroup: null)`
  trên MÃ THẬT ⇒ mô phỏng bằng cách tạm đổi ĐÚNG 1 chữ **trong test** (`approvalGroup: 1` → `null`).
  - 🔴 RED đo được: `Failed: 1` — `Expected g1.Items to contain 1 item(s), but found 2`
    (đúng chữ ký lỗi mà bản chưa vá sẽ tạo ra: 8 URL `?group=n` trả cùng tập mọi-nhóm).
  - 🟢 Hoàn nguyên → `Passed: 1`. Verify không dư lượng: `grep "approvalGroup: null)"` chỉ còn
    2 dòng HỢP LỆ (vế (1) không-lọc và vế (5) admin không-lọc).
  Cách này chạy trên production code THẬT (mạnh hơn probe mimic của T7) và giữ trọn rào không-đụng-prod.

## §10 — SỐ CHỐT CUỐI (đo tươi sau MAJOR-A)

- Filter `~ContractSigningPlanCrudTests`: **8/8 PASS** · filter `~KhkkGroupMenuSeed`: 7/7 PASS.
- **Infra: 577 PASS / 0 FAIL** (569 baseline + 7 K4a + 1 MAJOR-A) · **Domain: 45 PASS / 0 FAIL**.
- **Tổng suite = 622.** `git status -- src/` sạch phần của tôi (0 prod-code touched).

END sub-test-k4a — VERDICT=8/8 PASS (+8 test: 7 K4a + 1 gate-K4b MAJOR-A, suite 622 = 45 Domain + 577 Infra, 0 hồi quy, 0 prod-code touched)
