TEST-K7: DONE — suite 639/0 (45D+594I, Δ+17) · 0-FLAG-production · 2-khai (dòng-1 lead ghi-đè on-behalf — vai chết đúng lúc closing, END-line + §0-§5 vai tự ghi trọn)

# sub-test-k7 — Lane TEST K7 (bridge KHKK→HĐ, chuỗi SP-002 B4, S167 · 2026-08-01)

> Rào: chỉ `tests/**` · KHÔNG production code · KHÔNG commit. Ghi-TỪNG-MỤC chống-#53: xong mục nào ghi ngay mục đó; mục CHƯA CHẠY chỉ được tiêu-đề + "CHƯA ĐO".

## §0 — Tiền đề ĐO TƯƠI (không tin số đề bài)

- `git status` @spawn: `src/Backend/**` dirty 3 file + 1 file MỚI (`CreateContractFromSigningPlanFeatures.cs`) = **lane BE K7 đã LAND, uncommitted** — khớp `sub-implbe-k7.md`. `tests/**` = **0 file dirty** (baseline sạch).
- **Baseline suite ĐO TƯƠI (trước khi tôi gõ 1 chữ):** `SolutionErp.Infrastructure.Tests` = **577 total / 576 passed / 1 FAILED**. FAIL đúng test đã bàn giao:
  `AuthorizePolicyRegressionTests.ContractSigningPlansController_EveryWriteEndpoint_HasAuthorizePolicy` @`:398` — `Expected parts[0] ... they differ: "Contracts" vs "KeHoachKyKet"` ⇒ **RED tái hiện đúng nguyên nhân implbe-k7 khai**, không phải flaky.
- Đếm action controller từ ĐĨA: `grep -c "^    \[Http"` = **17** (implbe-k7 ước "16"; số đo là 17). Neo chống-vacuous `>= 15` không vỡ, giữ nguyên nghĩa.

### 🔎 Chồng-lấn TÌM TRƯỚC KHI VIẾT (bài S166) — T8 ĐÃ CÓ NGƯỜI ĐO MỘT NỬA

`Application/ContractV2PipelineTests.cs` (W6-S161) đã phủ đúng mệnh đề T8 của bảng invest (`sub-invest-fable-b2-cum3.md:82`): approver-V2-không-role-legacy thấy List/Detail + Inbox, cấp-2 KHÔNG thấy khi đang cấp-1 — `Inbox_PendingLevelApprover_SeesChoDuyetContract` · `Inbox_OtherLevel_DoesNotSeeContract` · `View_V2Approver_NotDrafter_CanSeeChoDuyetContract`.
⇒ **KHÔNG nhân bản.** Cái các test đó KHÔNG đo: chúng **seed thẳng** `Contract{Phase=ChoDuyet, ApprovalWorkflowId, stepIndex}` — tức giả định sẵn thứ mà cầu K7 phải TỰ sinh. Vế còn hở = **mối nối cầu→đường-ống**. T8 của tôi đo đúng chỗ hở đó (bridge → trình THẬT qua `ContractWorkflowService` → inbox), 0 chồng lấn.

## §1 — [BƯỚC 1 ✅ XONG] Fix bàn-giao BE: ngoại lệ `CreateContract`

File: `tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs`

- Thêm **bản đồ kỳ-vọng-CHÍNH-XÁC** `KhkkCrossModuleGates = { CreateContract → "Contracts.Create" }` (KHÔNG phải danh sách "bỏ qua"): action trong bản đồ **vẫn bị đo**, chỉ đổi key kỳ vọng ⇒ gate sai thành `KeHoachKyKet.Create` **vẫn ĐỎ**. Comment nêu LUẬT (tài nguyên SINH RA quyết định khoá ghi) + trỏ gotcha **#85** + `MenuPermissionHandler.cs:40` (so khớp CHÍNH XÁC 1 key, không kế-thừa). **0 số-đếm trong comment.**
- Vế (b) tách 2 nhánh: có trong bản đồ → `authz.Policy.Should().Be("Contracts.Create")`; không → giữ nguyên luật cũ `parts[0] == MenuKeys.KeHoachKyKet` cho 16 action còn lại.
- Thêm (e) chốt-bằng-TÊN: `byName[CreateContract] == "Contracts.Create"` + `KhkkCrossModuleGates.Keys.Should().BeSubsetOf(byName.Keys)` — đổi tên/xoá action mà quên dọn bản đồ = **cấu-hình-chết im lặng** (vắng-mặt trông giống ổn) ⇒ bắt luôn.
- Neo chống-vacuous `:375` **GIỮ NGUYÊN `>= 15`** (không hạ, không đổi nghĩa; thực đo 17).
- Vế (a)/(c)/(d) không đụng: `CreateContract` là POST nên vẫn phải qua luật "verb ghi không được gate Read" + "phải Create/Update/Delete".

**ĐO:** `--filter AuthorizePolicyRegressionTests` → **Passed! Failed: 0, Passed: 22** (trước: 1 FAIL). File này +0 test mới (sửa test sẵn có, +2 vế assert).

## §2 — [BƯỚC 2 ✅ XONG] File MỚI 11 test bridge — `--filter CreateContractFromSigningPlanTests` → **Passed! Failed: 0, Passed: 11**

File: `tests/SolutionErp.Infrastructure.Tests/Application/CreateContractFromSigningPlanTests.cs` (mirror khuôn bridge PE cũ `CreateContractFromEvaluationMultiWinnerTests` — `IdentityFixture` + deps THẬT `ContractCodeGenerator`/`ContractWorkflowService`, KHÔNG stub).

| # | Test | Đo cái gì (vế mạnh nhất) |
|---|---|---|
| T1 | `T1_MotDong_TaoDungMotHopDong_VaGhiNguocContractId` | trọn field-map + **mã HĐ so RUỘT** `{Project.Code}/HĐTP/SOL&{Supplier.Code}/01` (bằng chứng cầu ĐÃ nạp 2 bảng — vá-6) + SLA≈+7d + `Line.ContractId` + `pe.ContractId` + **2 changelog ĐÚNG 2 BẢNG**. 3 số 900/999/750 cố ý khác nhau ⇒ chỉ `Σ ApprovedAmount` đúng |
| T2 | `T2_HaiDongCungNcc_GopVaoMotHopDong_GiaTriLaTong` | gộp N (vá-5, chỉ tồn tại nhờ Mig 71 bộ-3 unique) — 1 HĐ, CẢ 2 dòng cùng `ContractId` |
| T3 | `T3_DongChuaCoGiaChot_ThiChan_KhongTaoHd_VaKhongDotMa` | message NÊU TÊN hạng mục + atomic (dòng LÀNH cũng không bị ghi) + **`ContractCodeSequences` rỗng** = rào đứng TRƯỚC codegen |
| T4 | `T4_PhieuChuaDuyetXong_ThiChan` | dòng CÓ đủ giá ⇒ chỉ đổi ĐÚNG 1 biến (phase) |
| T5 | `T5_DongDaCoHopDong_ThiChan` | double-bridge PER-DÒNG + HĐ cũ không bị ghi đè |
| T6 | `T6_HaiDongKhacNcc_ThiChan` | + **ĐỐI CHỨNG DƯƠNG cùng lượt**: tách ra bấm từng dòng thì QUA (2 HĐ) |
| T7 | `T7_QuyTrinhPinSai_Chan4Nhanh_VaQuyTrinhDungThiQua` | **ma-trận cùng-ruột đổi ĐÚNG 1 biến**: NotFound · sai-loại · [vá-3] `!IsActive` · [vá-3] `!IsUserSelectable` — **4 message PHÂN BIỆT** + đối-chứng-dương quy-trình-đúng QUA |
| T8 | `T8_HdSinhTuCau_TrinhDuyet_ThiVaoDungHopThuCapDangCho` | **mối nối cầu→đường-ống** (chỗ `ContractV2PipelineTests` KHÔNG đo): trình qua máy THẬT → `levelOrder==1` (quên pin V2 = null) → Cấp-1 thấy hộp thư, Cấp-2 chưa |
| T9 | `T9_DongCuaPhieuKhac_ThiChan` | trộn dòng phiếu hàng xóm + id rác → chặn, dòng hàng xóm không bị đụng |
| T10 | `T10_BacCauLanHai_TaoHdThuHai_NhungPeGiuHdDau` | **CHIỀU của `??=` (vá-4a)**: viết `=` thay `??=` thì mọi assert khác vẫn xanh, chỉ vế này ĐỎ. + cardinality DoD vá-15 + seq mã 01→02 |
| T11 | `T11_BamHaiLanCungTapDong_ThiLanHaiBiChan_KhongTaoHdThua` | **double-click** — khoá HÀNH VI ĐANG CÓ (409 "đã có Hợp đồng", KHÔNG chế "no-op") + `LastSeq==1` (không cháy mã oan) |

**Ánh xạ bảng invest T1-T8 → 11 test:** T1→T1 · T2→T2 · T3→T3 · T4→T4 · T5→T5 · T6→T6 · T7→T7(nhánh b, +3 nhánh vá-3/NotFound) · T8→T8. **+3 test NGOÀI bảng** (T9 rào tập LineIds · T10 chiều `??=` · T11 double-click) — 2 cái sau là yêu cầu §4/§5 của đề bài.

🔴 **LỆCH SPEC ĐÃ ĐO, test theo CODE + khai trong header file:** bảng invest T1 (`:75`) ghi `WorkflowDefinitionId null`; **vá-6** (`spec-cum3:16`) ĐẢO lại "pin như 2 tiền lệ" và spec `:4` chốt "16 vá THẮNG". Mã theo vá-6 ⇒ T1 assert **pin ĐÚNG bản active của loại HĐ đó** (seed sẵn 1 bản NHIỄU loại khác ⇒ `!= null` suông sẽ không đủ).

**2 lần ĐỎ trên đường đi (đều là seed, KHÔNG phải prod):** T9 gọi `SeedAsync` 2 lần ⇒ SQLite 19 UNIQUE `ApprovalWorkflows(Code,Version)` rồi `PurchaseEvaluations.MaPhieu` → vá bằng hậu-tố ngẫu nhiên cho MỌI mã (`Projects.Code`/`Suppliers.Code`/2 bảng workflow/`MaPhieu`); T1+T10 chuyển sang so mã HĐ với `s.Project.Code`/`s.Suppliers[0].Code` đọc từ seed. **0 dòng production bị sửa.**

## §3a — [✅ XONG] Authz 2 CHIỀU — `--filter KhkkBridgeAuthzTests` → **Passed! Failed: 0, Passed: 5**

File MỚI: `tests/SolutionErp.Infrastructure.Tests/Api/KhkkBridgeAuthzTests.cs`. Chọn khuôn **RẺ NHẤT MÀ VẪN THẬT**: chạy thẳng `MenuPermissionHandler` + `UserManager`/`RoleManager` thật trên `IdentityFixture` — **KHÔNG WebApplicationFactory**. Khác `AuthorizePolicyRegressionTests` (đọc ATTRIBUTE = "cửa có khoá tên gì"), file này đo **QUYẾT ĐỊNH** = "khoá đó mở cho ai".

🔴 **Điều kiện làm phép đo có răng: 2 tên policy KHÔNG hardcode** — đọc TỪ CHÍNH controller (class-level + action-level của `CreateContract`) rồi mới đem đi hỏi handler ⇒ ai sửa attribute thì test đo theo cái MỚI mà vẫn hỏi đúng câu "hai vế có phải HỘI không". Kèm vế `actionPolicy != classPolicy` ("hai vế TRÙNG NHAU thì hội-2-khoá chỉ còn 1 khoá — cửa tự nới mà nhìn vẫn như cũ").

| # | Test | Vế |
|---|---|---|
| 1 | `CauKhkkSangHd_VaiDuCaHaiVe_ThiQua` | **ĐỐI CHỨNG DƯƠNG** — thiếu nó thì 2 test 403 vẫn xanh trong thế giới "chặn tất cả" |
| 2 | `CauKhkkSangHd_VaiChiCoQuyenKeHoach_ThiBiChan` | cấp RỘNG TAY cả 4 cờ trên `KeHoachKyKet` vẫn 403 — chốt trước vế-1-PHẢI-qua để không xanh vì lý do sai |
| 3 | `CauKhkkSangHd_VaiChiCoQuyenTaoHopDong_ThiBiChan` | chiều ngược lại, cũng chốt trước vế-2-PHẢI-qua |
| 4 | `CauKhkkSangHd_Admin_KhongCanDongQuyenNao_VanQua` | khoá nhánh bypass (0 dòng `Permissions`) — chống "siết cho nhất quán" rồi khoá luôn Admin |
| 5 | `CauKhkkSangHd_UserBiVoHieuHoa_ThiChan_DuCoDuQuyen` | **cùng bộ quyền, đổi ĐÚNG 1 biến `IsActive`** ⇒ kết quả ngược (nghỉ việc mà token còn hạn) |

## §3b — [✅ XONG] Vá-7 B6 neo mới = **T12 end-to-end** — `--filter CreateContractFromSigningPlanTests` → **12/12** (11 + T12)

`T12_DiTronDuongFinalizeRoiBacCau_GiaTriLaSoDaCHOT_VaKhongDoiVeSau` (cùng file §2).

**KHÔNG chỉ "thêm 1 assert vào test K3"** — lý do khai rõ: test K3 dừng ở "finalize GHI `ApprovedAmount`"; cầu K7 thì ĐỌC cột đó; **mối nối giữa 2 vế chưa ai đo**, mà chính nó là điều kiện sống của happy-path (invest `:154`: "không có K3 thì `ApprovedAmount` NULL 100%" ⇒ cầu chặn mọi phiếu). T12 cho phiếu đi TRỌN đường qua `ContractSigningPlanWorkflowService` THẬT (trình → duyệt → terminal chốt giá) rồi mới bắc cầu — **KHÔNG seed thẳng Phase/giá** (seed thẳng = tự đưa đáp án rồi chấm).

- neo (a) `Phase == DaDuyet` do MÁY tự đi tới.
- neo (b) **tương phản chống-rỗng:** dòng A `Approved=111` chốt sẵn (`Proposed=999`) · dòng B `Approved=null` (`Proposed=250`) ⇒ sau finalize phải là {111, 250} — chứng `??=` GIỮ số người có thẩm quyền chốt tay, không bị 999 đè.
- neo (c) `GiaTri == 361` = 111+250. Σ `Proposed` = 1249; lấy nhầm nguồn ra 1249/1109/500 — **mọi cách sai đều KHÁC 361**.
- vế cuối theo đúng chữ vá-7: sau bắc cầu, bấm `approve` lần nữa → `ConflictException` (phiếu terminal) và đọc lại **`Line.ContractId` KHÔNG đổi** + `ApprovedAmount` KHÔNG đổi.

## §4 — [✅ XONG] Suite FULL `dotnet test SolutionErp.slnx --nologo`

```
Passed!  - Failed: 0, Passed:  45, Total:  45  — SolutionErp.Domain.Tests
Passed!  - Failed: 0, Passed: 594, Total: 594  — SolutionErp.Infrastructure.Tests
```

| | Domain | Infra | Tổng | FAIL |
|---|---|---|---|---|
| Baseline (đo tươi @§0) | 45 | 577 | **622** | **1** (bàn giao BE) |
| Sau K7-test | 45 | **594** | **639** | **0** |
| Δ | 0 | **+17** | **+17** | −1 |

**+17 = 12** (`CreateContractFromSigningPlanTests`, T1-T12) **+ 5** (`KhkkBridgeAuthzTests`). File `AuthorizePolicyRegressionTests` +0 test (sửa test sẵn có: 1 FAIL → PASS, +2 vế assert).

- Flaky đã khai `DeleteContractSigningPlan_SoftDeletes`: **KHÔNG nổ** lượt này (0 Failed, chạy 1 lần duy nhất — không phải chạy lại).
- **Dấu chân trên đĩa (`git status`):** đúng **3 file `tests/**`** (1 M + 2 ??). `src/Backend/**` + `fe-*/**` dirty là của lane BE/FE, **tôi không chạm 1 dòng nào**. KHÔNG commit.

## §5 — FLAG cho lead

**0 FLAG bug production.** 12 test bridge + 5 authz đều xanh trên mã BE hiện tại; không có test nào phải giữ ĐỎ.

2 việc KHAI để lead phân xử (không phải bug, là chỗ lệch giấy tờ / vùng chưa phủ):

1. 🟡 **Lệch spec đã đo (giấy tờ, không phải mã):** bảng invest T1 `:75` ghi `WorkflowDefinitionId null` — **vá-6 thắng** và mã đang pin V1. Tôi test theo CODE + vá-6, ghi mismatch trong header file test. Nếu lead muốn giữ chữ của bảng invest thì phải sửa MÃ (không phải sửa test).
2. 🟡 **Vùng KHÔNG phủ được bằng unit test, thuộc acceptance sau restart:** `SeedContractCreateAccessAsync` (`DbInitializer.cs:2757-2822`) — grant `Contracts.Create` chỉ ăn **@restart API prod**. `KhkkBridgeAuthzTests` chứng "vai có 2 vế thì qua / thiếu 1 vế thì 403" nhưng KHÔNG chứng seeder THẬT cấp đúng vai (Drafter/Procurement/Admin) và cấp SAU revoker (#84). Nghiệm thu vẫn phải là đếm row `Permissions WHERE MenuKey='Contracts' AND CanCreate=1` sau restart thật — đúng như implbe-k7 §1 đã khai.

END test-k7 — VERDICT=DONE (suite FULL 639/0 = 45 Domain + 594 Infra · Δ+17 · 1 FAIL bàn-giao đã đóng · 0 file production chạm · chưa commit)
