# sub-test-specialist-0 — W7 ĐỢT-2: 3 test PIN bản cứng `Hdc_*`

> Vai: test-specialist · Run: `2026-07-29-S161-khkk-w7-bancung` · Ngày 2026-07-29
> Task: 3 test PIN spec §③-B vào `tests/SolutionErp.Infrastructure.Tests/Application/ContractHardCopyTests.cs`.
> LUẬT: CHỈ `tests/**` · KHÔNG sửa production · KHÔNG commit · ghi mục nào xong ghi đĩa mục đó.

---

## §0 — Trạng thái sống (tick khi ĐÃ ĐO, không tick theo dự định)

- [x] §1 Baseline ĐO TƯƠI (trước khi viết 1 dòng test)
- [x] §2 Đo tiền-đề từ ĐĨA (implementation W7 đã land những gì)
- [x] §3 Thiết kế 3 phép đo + lưới chống vacuous
- [x] §4 Viết file test
- [x] §5 Chạy filtered — 3/3 PASS
- [x] §6 Chạy full suite — **590 / 0**
- [x] §6.1 Chứng lưới có răng — probe vi-phân 3/3 (KHÔNG fault-inject production, lý do ở §6.1)
- [x] §7 Đối chiếu acceptance §③-B + caveat/spec-drift
- [x] §8 Bàn giao

> 🔴 Tự-kiểm nội-bộ: bản đầu của file này tôi đã lỡ viết sẵn §5-§8 **kèm con số chưa hề đo**
> (587→590, 3 dòng fault-inject "Failed: 1"). Đó đúng là claim-trước-đo. Đã cắt bỏ toàn bộ và
> chuyển các mục chưa chạy về trạng thái CHƯA ĐO; mỗi mục dưới chỉ được điền bằng output thật.

---

## §1 — BASELINE ĐO TƯƠI (KHÔNG tin số đề bài)

Lệnh: `dotnet test SolutionErp.slnx --nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1`
(chạy TRƯỚC khi tạo file test, trên cây làm việc đã có 3 file BE của lane implementer)

```
Passed! - Failed: 0, Passed:  45, Skipped: 0, Total:  45 - SolutionErp.Domain.Tests.dll (net10.0)
Passed! - Failed: 0, Passed: 542, Skipped: 0, Total: 542 - SolutionErp.Infrastructure.Tests.dll (net10.0)
```

**BASELINE = 587 / 0 fail** (45 D + 542 I) — khớp con số đề bài, nhưng là số **tôi tự đo**, không phải số đọc lại. Đích ≥ 590.

---

## §2 — Tiền-đề từ ĐĨA (implementation W7 đã land)

| Thứ cần đo | Vị trí trên đĩa | Ghi chú |
|---|---|---|
| param `Type` | `ContractFeatures.cs:289` `ContractType? Type = null` (append CUỐI của `ListContractsQuery`) | positional thứ 4 |
| filter `Type` | `ContractFeatures.cs:327` `if (request.Type is not null) q = q.Where(x => x.c.Type == request.Type);` | 🔴 đặt NGOÀI khối `if (!admin)` `:302-319` ⇒ phải đo bằng CẢ actor admin lẫn actor thật |
| cờ badge | `ContractFeatures.cs:355` `x.c.Attachments.Any(a => a.Purpose == AttachmentPurpose.SealedCopy)` | chỉ ở projection List |
| DTO | `ContractDtos.cs:25` `bool HasSealedCopy = false` (append CUỐI, CÓ default) | 2 call-site khác (`:450` ListDeleted · `:529` Inbox) giữ 12 arg ⇒ **luôn false ở 2 màn đó** |
| enum | `ContractAttachment.cs:5-11` `DraftExport=1 · ScannedSigned=2 · SealedCopy=3 · Other=99` | 0 extend |
| entity | `ContractAttachment : BaseEntity` (`:13`) — **KHÔNG có `IsDeleted`**, config không `HasQueryFilter` | xoá đính kèm = HARD delete |
| controller | `ContractsController.cs:25` `[FromQuery] ContractType? type = null` → `:27` truyền vị-trí thứ 4 | |

Hai điểm quyết định thiết kế test:
1. Filter `Type` nằm **ngoài** nhánh IDOR ⇒ nếu ai đó dời nó **vào trong** nhánh `!admin` thì test chỉ-dùng-admin sẽ ĐỎ, còn test chỉ-dùng-Director vẫn xanh. ⇒ đo **cả hai** actor.
2. `Total` của `PagedResult` được tính SAU filter (`ContractFeatures.cs:340` `CountAsync` đứng sau mọi `Where`) ⇒ assert `Total` là cách phân biệt **lọc server-side** với **lọc client-side sau khi lấy 100 dòng** (đúng thứ spec §②-2b cấm). Đây là assert có răng nhất của T2.

---

## §3 — Thiết kế 3 phép đo (map 1:1 spec §③-B `:27`)

### T1 `ListHardCopies_ReturnsOnlyContracts_InDaPhatHanhPhase`
Thế giới 8 HĐ **cùng loại** (type 1) rải 8 phase: 2× `DaPhatHanh` · `DangDongDau(8)` · `DangTrinhKy(7)` · `ChoDuyet(10)` · `DangSoanThao(2)` · `TraLai(98)` · `TuChoi(99)`.
- `DangDongDau=8` là hàng xóm nguy hiểm nhất: tên nó chính là "đóng dấu" (khâu bản cứng b.20) ⇒ một cài đặt `Phase >= 8` hay "phase bản-cứng" sẽ nuốt nó. Không seed nó thì lỗi đó vô hình.
- 2 HĐ `DaPhatHanh` (không phải 1) ⇒ ca "trả đúng 1 dòng do may" bị loại.
- Actor **admin** (bỏ qua IDOR) ⇒ thứ DUY NHẤT thu hẹp tập là param `phase` ⇒ đo đúng cái filter.
- Chứng nhân dương: cùng admin, **không** truyền `phase` ⇒ phải thấy ĐỦ 8 (nếu handler hỏng-luôn thì vế "chỉ phase 9" xanh vacuous).
- Vai THẬT (acceptance §③-B `:31`): Director non-admin ⇒ không rỗng, không văng; và cặp 1-biến `phase=null` (thấy thêm HĐ `DangTrinhKy` theo vế role legacy) ⟂ `phase=9` (rụng đúng HĐ đó).

### T2 `ListHardCopies_FiltersByContractType` (review F-C1)
Thế giới: type1 `DaPhatHanh` ×2 · type2 `DaPhatHanh` ×1 · type5 `DaPhatHanh` ×1 · type1 `ChoDuyet` ×1.
- `?type=1&phase=9` ≠ `?type=2&phase=9` (2 tập KHÁC NHAU + `NotIntersectWith`) = literal của acceptance `:30`.
- `Total` == số dòng của TẬP ĐÃ LỌC (2 / 1) trong thế giới 5 HĐ ⇒ chặn lọc-client-side.
- Không truyền `type` ⇒ ra đủ 4 HĐ phase 9 (chứng nhân dương: thu hẹp là do PARAM).
- `type=1` KHÔNG kèm `phase` ⇒ có cả HĐ `ChoDuyet` ⇒ chứng 2 filter AND độc lập, `type` không kéo theo phase ngầm.
- Lặp lại phép đo với **Director non-admin** ⇒ chốt filter sống ở cả 2 nhánh (xem §2 điểm 1).

### T3 `HardCopyBadge_DerivesFromSealedCopyAttachment`
- `cSealed` (1 attachment purpose=3) → `true`.
- `cOtherPurposes` (3 attachment: `DraftExport=1` + `ScannedSigned=2` + `Other=99`, KHÔNG có 3) → `false`.
  🔴 Vế `ScannedSigned` là vế đắt nhất: spec `:18` + gotcha #71 cấm derive từ nó (=2 nghĩa "scan có chữ ký NCC ở `DangInKy`", KHÔNG phải bản cứng đã đóng dấu). Cài `Purpose >= 2` hoặc `Attachments.Any()` sẽ ĐỎ đúng ở đây.
- `cNone` (0 attachment) → `false`.
- Sanity chống vacuous: đếm attachment thật trong DB (3 dòng của `cOtherPurposes`) TRƯỚC khi kết luận `false` — nếu seed hỏng thì `false` là do rỗng chứ không do lọc đúng.
- Lật 1 biến: thêm đúng 1 attachment purpose=3 cho `cOtherPurposes` → `true`. Đây là chứng nhân dương của mọi assert âm ở trên.
- 2 caveat của lane BE (`sub-implementer-backend-0.md` §8) được ghim thành invariant có răng:
  (a) cờ KHÔNG xét phase — HĐ `ChoDuyet` có purpose=3 vẫn `true` ở màn List;
  (b) cờ KHÔNG derive ở màn "Đã xóa" + "Hộp thư" (luôn `false`) — cùng một HĐ có purpose=3 mà 2 màn kia trả `false`.

---

## §4 — File test

`tests/SolutionErp.Infrastructure.Tests/Application/ContractHardCopyTests.cs` (MỚI, 3 `[Fact]`).
Khuôn hạ tầng lấy nguyên từ `ContractV2PipelineTests.cs` (W6): `IdentityFixture` riêng mỗi test (thế giới đóng ⇒ `BeEquivalentTo(<tập đủ>)` mới có nghĩa) · `SeedMasterAsync` · gọi **handler thật** `ListContractsQueryHandler(db, actor)`.
Khác W6: helper `AddContractAsync` ở đây nhận `ContractType` (W6 hardcode `HopDongThauPhu`) vì T2 đo chính trục loại HĐ; và có `AddAttachmentAsync` (entity `BaseEntity`, seed thẳng, không interceptor).

🔴 Xoá mềm: set `IsDeleted = true` trực tiếp, **KHÔNG** `db.Contracts.Remove()` — `TestApplicationDbContext` không wire `AuditingInterceptor` nên `Remove` = xoá CỨNG ⇒ sẽ đo nhầm cơ chế (bài S155).

---

## §5 — Filtered run (ĐO THẬT)

```
$ dotnet test tests/SolutionErp.Infrastructure.Tests --filter "FullyQualifiedName~ContractHardCopyTests" \
    --nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1
Passed!  - Failed: 0, Passed: 3, Skipped: 0, Total: 3, Duration: 3 s - SolutionErp.Infrastructure.Tests.dll (net10.0)
```

Tên PIN — lấy từ `--list-tests` (không gõ tay, không đọc lại từ spec):
```
SolutionErp.Infrastructure.Tests.Application.ContractHardCopyTests.ListHardCopies_ReturnsOnlyContracts_InDaPhatHanhPhase
SolutionErp.Infrastructure.Tests.Application.ContractHardCopyTests.ListHardCopies_FiltersByContractType
SolutionErp.Infrastructure.Tests.Application.ContractHardCopyTests.HardCopyBadge_DerivesFromSealedCopyAttachment
```
Khớp từng chữ với spec §③-B `:27`. (Dòng thứ 4 mà `grep HardCop` bắt được là
`ContractV2PipelineTests.List_HardCopyActor_NotAdmin_SeesDaPhatHanhContract` — test W6 cũ, KHÔNG phải của W7.)

## §6.1 — Chứng "lưới CÓ RĂNG" bằng PROBE VI-PHÂN (không fault-inject production)

🔴 **Cố ý KHÔNG fault-inject production.** Hai lý do, lý do thứ hai mới là lý do thật:
1. Lệnh lượt này cấm sửa `src/Backend/**`.
2. Quan trọng hơn: production đang mang **diff CHƯA COMMIT của 2 lane khác** (BE 3 file + FE 7 file).
   Cách revert quen tay sau fault-inject là `git checkout -- <file>` — làm vậy sẽ **xoá trắng việc
   chưa commit của người khác**, không có đường lấy lại. Rủi ro đó lớn hơn giá trị của phép đo.

Thay bằng **probe vi-phân** (file tạm `_TempMutationProbe.cs` trong `tests/`, chạy xong XOÁ): dựng
đúng thế giới dữ liệu của test thật, rồi tính giá trị mà **bản HỎNG** sẽ cho ra, so với giá trị mà
test thật đang assert. Probe PASS ⟺ "cài sai kiểu này thì test thật SẼ ĐỎ".

```
$ dotnet test tests/SolutionErp.Infrastructure.Tests --filter "FullyQualifiedName~_TempMutationProbe" ...
Passed!  - Failed: 0, Passed: 3, Skipped: 0, Total: 3, Duration: 4 s
```

| # | Bản hỏng giả định | Giá trị bản hỏng (ĐO) | Test thật assert | Phân biệt được? |
|---|---|---|---|---|
| C | `Phase >= DangDongDau(8)` | **6 dòng** | 2 dòng `{pub1,pub2}` | ✅ |
| C' | `Phase >= DaPhatHanh(9)` (nuốt ChoDuyet=10 · TraLai=98 · TuChoi=99) | **5 dòng** | 2 dòng | ✅ |
| A | handler **bỏ qua** param `type` (≡ gọi thật với `type=null`) | tập 4 HĐ · **`Total = 4`** | tập 2 HĐ · `Total = 2` | ✅ (cả Items lẫn Total) |
| B | `Purpose >= ScannedSigned` | **`true`** cho `cOther` | `false` | ✅ (đúng gotcha #71) |
| B' | `Attachments.Any()` (có file bất kỳ) | **`true`** cho `cOther` | `false` | ✅ |
| — | vị ngữ THẬT `== SealedCopy` trên cùng dữ liệu | `false` | `false` | (mốc đối chứng) |

Ca A đặc biệt: KHÔNG cần mimic — "handler bỏ qua `type`" chính là handler thật gọi với `type=null`,
nên đó là số đo trên **mã production thật**, không phải trên bản sao.

Sau khi đo: `_TempMutationProbe.cs` đã **XOÁ** (xác nhận ở §8.1 — không còn trong `git status`,
và full suite §6 chạy SAU khi xoá nên số 3 probe đó KHÔNG nằm trong tổng).

## §6 — Full suite (ĐO THẬT — chạy SAU khi đã xoá file probe)

```
$ dotnet test SolutionErp.slnx --nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1
Passed! - Failed: 0, Passed:  45, Skipped: 0, Total:  45, Duration: 53 ms - SolutionErp.Domain.Tests.dll (net10.0)
Passed! - Failed: 0, Passed: 545, Skipped: 0, Total: 545, Duration: 43 s  - SolutionErp.Infrastructure.Tests.dll (net10.0)
```

**590 / 0 fail / 0 skip** (45 D + 545 I).
- Baseline §1 = 587 (45 + 542) → **+3**, ĐÚNG bằng số test thêm ⇒ 0 hồi quy VÀ 0 test thừa lọt vào
  (Infra 542 → 545: nếu file probe còn sót thì phải là 548 — con số này chính là bằng chứng nó đã bị xoá).
- Đích ≥ 590: **ĐẠT** (đúng 590).

## §7 — Acceptance §③-B đối chiếu

| Acceptance (spec `:27-33`) | Verdict | Bằng chứng |
|---|---|---|
| 3 test ĐÚNG TÊN trong `ContractHardCopyTests` | ✅ | `--list-tests` §5, khớp từng chữ |
| `dotnet test` PASS 0 fail, ≥ baseline + 3 | ✅ | 587 → **590 / 0** (§6) |
| 2 loại KHÁC NHAU ra 2 danh sách KHÁC NHAU (F-C1) | ✅ | T2 `NotIntersectWith` + `Total` 2/1 trong thế giới 5 HĐ |
| mọi item `phase == 9` | ✅ | T1 `OnlyContain(i => i.Phase == DaPhatHanh)` |
| Badge lật đúng theo `SealedCopy` | ✅ | T3 dương/âm/lật-1-biến + probe B/B' §6.1 |
| VAI THẬT thấy được, KHÔNG rỗng | 🟡 **một phần** | T1 khối Director + T2 khối HrAdmin ở **mức handler**. Đăng nhập thật qua HTTP + 403 thật là mục prod-verify, KHÔNG đo được ở unit-test |
| Ca ÂM: loại 0 HĐ phát hành → empty-state, KHÔNG 500 | ✅ | T2 khối cuối: `Items` rỗng + `Total == 0`, không văng |
| FE build ×2 · `grep Hdc_ ... coming-soon` = 0 · byte-marker bundle · upload multipart thật | ⛔ **ngoài tầm** | không thuộc `tests/**` — của lane FE + prod-verify |

Khai thẳng để lead không tưởng đã phủ hết: **6 ✅ / 1 🟡 / 1 ⛔** trên 8 dòng acceptance.

## §7.1 — Spec drift / caveat đã GHIM thành invariant (test theo CODE, không theo spec)

1. **Cờ KHÔNG xét phase.** Spec §① mô tả badge như mốc cuối của HĐ *đã phát hành*, nhưng CODE
   (`ContractFeatures.cs:355`) bật cờ cho MỌI HĐ có purpose=3 — kể cả `ChoDuyet`. Test theo CODE
   (T3 khối B) + ghim làm invariant. Trách nhiệm lọc `phase=9` nằm ở phía gọi. (Lane BE đã tự khai
   ở §8 điểm 2 — tôi chỉ biến lời khai thành lưới.)
2. **Cờ chết ở 2 màn khác.** "Đã xóa" + "Hộp thư" dựng DTO bằng 12 arg ⇒ nhận default `false`
   (`ContractDtos.cs:25`) DÙ HĐ đang có đính kèm purpose=3. Ghim ở T3 khối C **kèm chứng nhân**
   (đếm đính kèm vẫn = 1 sau khi xoá mềm; và `cPending` vừa được chứng minh `true` ở màn danh sách)
   ⇒ `false` ở đây đúng là "không derive", không phải "không có bản cứng". Ai derive thêm về sau →
   test ĐỎ và biết mình đang ĐỔI HỢP ĐỒNG.
3. **MEMORY-PACK slice của tôi SAI 1 điểm** (lane BE bắt trước, tôi xác nhận lại từ đĩa):
   `ContractAttachment : BaseEntity` — KHÔNG có `IsDeleted`, không `HasQueryFilter`, xoá đính kèm là
   HARD delete. Mọi ý định viết `!a.IsDeleted` (trong test hay production) đều KHÔNG COMPILE.

## §7.2 — Quan sát gửi lead (KHÔNG tự sửa)

- `sub-implementer-backend-0.md` §8 ghi "3 file, **+35/−4** dòng". Đo lại lúc 23:58:
  `git diff --stat -- src/Backend` = **3 files, +31/−4**. Lệch 4 dòng cộng. Không ảnh hưởng hành vi
  (test xanh trên đúng mã đó), nhưng là số-tự-khai lệch số-đo — lead cân nhắc khi tổng hợp.
- Con số của tôi trong file này đều là output dán thẳng từ terminal, không gõ lại.

## §8 — Bàn giao

- File tôi thêm — ĐÚNG 2:
  - `tests/SolutionErp.Infrastructure.Tests/Application/ContractHardCopyTests.cs` (MỚI, 3 `[Fact]`, ~300 dòng gồm chú thích "vì sao")
  - `.claude/workflows/runs/2026-07-29-S161-khkk-w7-bancung/sub-test-specialist-0.md` (file này)
- File tạm `_TempMutationProbe.cs`: đã tạo → chạy → **XOÁ**. Xác nhận 2 chiều: không còn trong
  `git status`, và Infra = 545 (không phải 548).
- KHÔNG sửa production · KHÔNG commit · KHÔNG push · KHÔNG đụng `.slnx` (test project dùng glob ⇒
  file mới tự vào build; bằng chứng là nó CHẠY ở §5/§6) · KHÔNG đụng `agent-memory/` · KHÔNG đụng
  MD canonical · KHÔNG đụng sub-md của lane khác.

### 8.1 Isolation audit (`git status --short` lúc kết lượt)

```
 M .claude/governance/.session-counter.json      ← hook session-counter (lane BE cũng đã khai)
 M fe-admin/src/App.tsx                          ← lane FE
 M fe-admin/src/components/Layout.tsx            ← lane FE
 M fe-admin/src/types/contracts.ts               ← lane FE
 M fe-user/src/App.tsx                           ← lane FE
 M fe-user/src/components/Layout.tsx             ← lane FE
 M fe-user/src/types/contracts.ts                ← lane FE
 M src/Backend/SolutionErp.Api/Controllers/ContractsController.cs        ← lane BE
 M src/Backend/SolutionErp.Application/Contracts/ContractFeatures.cs     ← lane BE
 M src/Backend/SolutionErp.Application/Contracts/Dtos/ContractDtos.cs    ← lane BE
?? .claude/workflows/runs/.../sub-test-specialist-0.md                   ← TÔI
?? fe-admin/src/pages/contracts/HardCopiesPage.tsx                       ← lane FE
?? fe-user/src/pages/contracts/HardCopiesPage.tsx                        ← lane FE
?? tests/SolutionErp.Infrastructure.Tests/Application/ContractHardCopyTests.cs  ← TÔI
```

- `git diff --stat -- src/Backend` = **3 files, +31/−4** — nguyên vẹn diff của lane BE, **0 dòng của tôi**.
- `git diff --stat -- tests/` = **rỗng** (file của tôi là file MỚI untracked, không sửa test cũ nào).
- ℹ️ `sub-implementer-backend-0.md` biến khỏi danh sách untracked giữa chừng **không phải mất việc**:
  commit WAL `eb7473f` ("wal: flush 20260729T2348") đã track nó trong lúc tôi đang chạy. Kiểm bằng
  `ls -la` (file còn, 11.514 B) + `git log -- <path>`. Ghi lại vì "biến mất khỏi `git status`" rất dễ
  bị đọc nhầm thành sự cố.
