# sub-test-specialist-0 — W1 KHKK Mig 69: schema-invariant test + codegen format test

> Ghi TỪNG MỤC trong lúc làm (chống #53). Run: `2026-07-29-S161-khkk-w1-schema`.
> Vai: test-specialist (WRITE độc quyền `tests/**`). KHÔNG sửa production, KHÔNG commit.

---

## §0 — Tiền đề đọc từ ĐĨA (trước khi viết 1 dòng test)

Spec đọc: `.claude/workflows/runs/2026-07-29-S160-khkk-dryrun-plan/spec-wave-w1-schema-designer-29-07-2026.md`
- §③-B dòng 35: test PIN tên `ContractSigningPlanSchemaTests.BayBang_MoiBang_CoAuditFields_VaQueryFilter`, sàn `N ≥ 2`, số-sau ≥ số-trước + 2.
- §③-B dòng 36: test PIN tên `SigningPlanCodeGenerator_Format_KhkkYearSeqD3` — regex `^KHKK/\d{4}/\d{3}$` + 2 lần gọi seq tăng.

### 7 entity — base class ĐO THẬT trên đĩa (KHÔNG đoán)

| # | Entity | File | Base | Query filter (config) |
|---|---|---|---|---|
| 1 | `ContractSigningPlan` | `Domain/ContractSigningPlans/ContractSigningPlan.cs:16` | `AuditableEntity` | ✅ `ContractSigningPlanConfiguration.cs:47` |
| 2 | `ContractSigningPlanLine` | `ContractSigningPlanLine.cs:14` | `AuditableEntity` | ✅ `ContractSigningPlanLineConfiguration.cs:36` |
| 3 | `ContractSigningPlanDossierItem` | `ContractSigningPlanDossierItem.cs:31` | `AuditableEntity` | ✅ `...DossierItemConfiguration.cs:29` |
| 4 | `ContractSigningPlanAttachment` | `ContractSigningPlanAttachment.cs:24` | `AuditableEntity` | ✅ `...AttachmentConfiguration.cs:39` |
| 5 | `ContractSigningPlanLevelOpinion` | `ContractSigningPlanLevelOpinion.cs:24` | `AuditableEntity` | ❌ **CỐ Ý KHÔNG có** |
| 6 | `ContractSigningPlanChangelog` | `ContractSigningPlanChangelog.cs:26` | `BaseEntity` | ❌ (đúng — no `IsDeleted`) |
| 7 | `ContractSigningPlanApproval` | `ContractSigningPlanApproval.cs:19` | `BaseEntity` | ❌ (đúng — no `IsDeleted`) |

🔴 **LỆCH so mô-tả trong TASK-prompt** (prompt liệt `LevelOpinion = AuditableEntity có IsDeleted` ⇒ ngụ ý phải có query filter). ĐĨA nói khác: `ContractSigningPlanLevelOpinion` LÀ `AuditableEntity` **nhưng CỐ Ý KHÔNG HasQueryFilter**, có lý do viết sẵn 2 chỗ:
- entity `ContractSigningPlanLevelOpinion.cs:20-23`: *"CỐ Ý KHÔNG có HasQueryFilter (parity 7 twin còn lại): site 'dọn chữ ký MỒ CÔI' (ApprovalWorkflowV2AdminFeatures ExecuteDeleteAsync) phải THẤY được row đã soft-delete, nếu bị filter che thì row mồ côi sống sót và FK Restrict lại nổ 500."*
- config `ContractSigningPlanLevelOpinionConfiguration.cs:12-16`: cùng nội dung.

⇒ Áp luật MEMORY "spec drift detection BEFORE write (S34)": **test theo CODE**, không theo prompt. Nhưng KHÔNG bỏ qua — assert LevelOpinion **KHÔNG có filter** như một invariant CÓ CHỦ Ý (nếu ai đó thêm filter sau này ⇒ test ĐỎ ⇒ buộc đọc lại lý do). Đây là assert có RĂNG, không phải né.

### Codegen
`src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanCodeGenerator.cs`
- ctor `(IApplicationDbContext db, IDateTime dateTime)` — `:12`
- `GenerateAsync(CancellationToken ct = default)` — KHÔNG nhận entity (khác PE nhận `PurchaseEvaluation`)
- prefix `$"KHKK/{year}"`, return `$"{prefix}/{seq.LastSeq:D3}"` — `:20`, `:45`
- `BeginTransactionAsync(IsolationLevel.Serializable, ct)` — `:24-25`
- sequence sống ở `db.WorkflowAppCodeSequences` (bảng DÙNG CHUNG Office), PK `Prefix`

### Tiền lệ SQLite + Serializable — ĐÃ CÓ, không cần chế mới
`tests/SolutionErp.Infrastructure.Tests/Common/SqliteDbFixture.cs:36-42` ghi rõ:
> *"SQLite hỗ trợ transactions thật (BEGIN/COMMIT/ROLLBACK), tuy nhiên IsolationLevel enum value bị provider mapping gracefully (no exception, just default behavior)"*

Và `PurchaseEvaluationCodeGeneratorTests.cs:17` gọi thẳng `new PurchaseEvaluationCodeGenerator(fix.Db, dt)` — generator PE cũng `Serializable`, test cũ chạy XANH nhiều session ⇒ **dùng y khuôn, KHÔNG đụng production**.

`FixedDateTime` stub: `SqliteDbFixture.cs:71`.

---

## §1 — Test đã viết

File MỚI **duy nhất**: `tests/SolutionErp.Infrastructure.Tests/Common/ContractSigningPlanSchemaTests.cs`
Đặt ở `Common/` cạnh `ContractV2SchemaPersistenceTests.cs` (sibling gần nhất về NGHĨA: schema-invariant
của họ `*LevelOpinion` + workflow V2), namespace `SolutionErp.Infrastructure.Tests.Common`.
**4 test** (sàn spec là N ≥ 2):

| # | Test | Nội dung | Vế chống vacuous |
|---|---|---|---|
| 1 | `BayBang_MoiBang_CoAuditFields_VaQueryFilter` **(PIN spec)** | Quét EF model theo namespace `SolutionErp.Domain.ContractSigningPlans` → phải ra ĐÚNG 7 entity; mỗi bảng: tên bảng vật lý + 4 cột audit + bộ-3 soft-delete + có/không query filter | (a) `BeEquivalentTo` bộ 7 tên ⇒ thêm bảng #8 mà quên khai = ĐỎ; (b) soft-delete assert **2 chiều** (BaseEntity phải KHÔNG có `IsDeleted`); (c) đuôi test seed 1 phiếu `IsDeleted=true` → filter phải giấu (0) mà `IgnoreQueryFilters` vẫn thấy (1) ⇒ chứng filter là BIỂU THỨC THẬT, không chỉ "có khai" |
| 2 | `SigningPlanCodeGenerator_Format_KhkkYearSeqD3` **(PIN spec)** | 2 lần `GenerateAsync()` → regex `^KHKK/\d{4}/\d{3}$` + giá trị tuyệt đối `KHKK/2026/001` → `/002` + `Seq(code2)==Seq(code1)+1` + row `WorkflowAppCodeSequences.Prefix=="KHKK/2026"` có `LastSeq==2` | đẩy đồng hồ sang **2027** → đòi `KHKK/2027/001` (reset) + hàng 2026 giữ `LastSeq==2`. Không có vế này thì `\d{4}` vẫn xanh dù năm bị **hardcode** |
| 3 | `Line_UniqueFilteredTheoIsDeleted_ChoXoaMemRoiThemLaiCungNcc` | gotcha #57: xoá mềm 1 dòng NCC rồi thêm LẠI đúng NCC đó ⇒ KHÔNG được nổ UNIQUE; active==1, `IgnoreQueryFilters`==2 | 🔴 **vế đối chứng bắt buộc**: 2 dòng **CÒN SỐNG** cùng `(PlanId, SupplierId)` ⇒ PHẢI ném `DbUpdateException`. Không có vế này thì "không nổ" cũng đúng trong thế-giới **index chưa từng được tạo** trên SQLite |
| 4 | `ApplicableType_ContractSigningPlan_PinSlot10_KhongTrungSlot` | `(int)ApprovalWorkflowApplicableType.ContractSigningPlan == 10` + không member nào khác chiếm slot 10 | enum này `HasConversion<int>` (`ApprovalWorkflowConfiguration.cs:17`) ⇒ số ĐÃ ghi xuống DB; đánh số lại = quy trình đã pin trỏ sai module, hỏng IM LẶNG |

Tiền lệ tái dùng, KHÔNG chế hạ tầng mới: `SqliteDbFixture` + `FixedDateTime` (`Common/SqliteDbFixture.cs:43,71`).
KHÔNG cần `IdentityFixture` (không seed User/Level ⇒ tránh FK Restrict `ApprovalWorkflowLevel→User`).

---

## §2 — Số đo

### Baseline ĐO THẬT ngay trước khi thêm test (không lấy số nghe kể)
```
Passed!  - Failed: 0, Passed:  45, Total:  45  — SolutionErp.Domain.Tests.dll
Passed!  - Failed: 0, Passed: 517, Total: 517  — SolutionErp.Infrastructure.Tests.dll  (1 m 17 s)
```
⇒ **562** = 45 + 517 — KHỚP con số spec ghi (562).

### Filtered run 4 test mới
```
Passed!  - Failed: 0, Passed: 4, Skipped: 0, Total: 4, Duration: 1 s
--filter "FullyQualifiedName~ContractSigningPlanSchemaTests"
```

### 🔬 Fault-injection chứng test CÓ RĂNG (bơm lỗi vào BẢNG KỲ VỌNG của chính test — file tests/, KHÔNG đụng production)
Áp luật "fault-injection proves teeth": một detector luôn-trả-True cũng cho 4/4 xanh, nên phải ép ĐỎ **cả 2 chiều**.

- **Chiều A** — đổi kỳ vọng `ContractSigningPlan` từ `QueryFilter=true` → `false`:
  ```
  Expected hasFilter to be False because header AuditableEntity — HasQueryFilter(!IsDeleted)
  `ContractSigningPlanConfiguration.cs:47`, but found True.
  Failed! - Failed: 1, Passed: 0     (ContractSigningPlanSchemaTests.cs:line 108)
  ```
  ⇒ chứng bộ đo đọc ra **True** ở bảng CÓ filter.
- **Chiều B** — đổi kỳ vọng `ContractSigningPlanLevelOpinion` từ `false` → `true`:
  ```
  Expected hasFilter to be True because AuditableEntity NHƯNG CỐ Ý KHÔNG filter — …, but found False.
  Failed! - Failed: 1, Passed: 0
  ```
  ⇒ chứng bộ đo đọc ra **False** ở bảng KHÔNG filter.

Hai chiều ⇒ `((IReadOnlyEntityType)et).GetDeclaredQueryFilters().Any()` **phân biệt được** hai thế giới, không phải hằng số. Cả 2 lần bơm đã **revert về đúng trạng thái đĩa** ngay sau khi đo.

### Full suite sau khi thêm
**[LEAD điền on-behalf @S161 — vai chết #53 lúc đợi job nền; số do lead chạy TƯƠI]**
```
Passed!  - Failed: 0, Passed:  45, Total:  45 — SolutionErp.Domain.Tests.dll
Passed!  - Failed: 0, Passed: 521, Total: 521 — SolutionErp.Infrastructure.Tests.dll (50s)
```
⇒ **566 = 562 + 4** (sàn spec ≥ 564 VƯỢT). 0 fail.

---

## §3 — Ghi chú kỹ thuật đáng nhớ

1. **EF Core 10 đổi API query-filter.** EF 10 có filter CÓ TÊN ⇒ ngoài `GetQueryFilter()` (cũ, ném khi có
   nhiều filter tên) còn `GetDeclaredQueryFilters()`. Probe XML doc gói thật đang restore
   (`~/.nuget/packages/microsoft.entityframeworkcore/10.0.6/lib/net10.0/*.xml`) thấy CẢ HAI còn sống;
   chọn `GetDeclaredQueryFilters()` + ép kiểu `(IReadOnlyEntityType)` để khỏi dính obsolete/ambiguity.
   Repo KHÔNG bật `TreatWarningsAsErrors` (grep 0 hit) nên đây là chọn cho sạch, không phải bắt buộc.
2. **Serializable trên SQLite — KHÔNG phải vấn đề**, đã có tiền lệ ghi sẵn ở `SqliteDbFixture.cs:36-42`
   ("IsolationLevel enum bị provider map gracefully") và `PurchaseEvaluationCodeGeneratorTests.cs:17`.
   ⇒ gọi thẳng `new ContractSigningPlanCodeGenerator(fix.Db, clock)`, KHÔNG sửa production.
3. **`HasFilter("[IsDeleted] = 0")` viết kiểu SQL Server VẪN chạy trên SQLite** — SQLite chấp nhận
   định danh trong `[...]` và có partial index. Test 3 xanh cả 2 vế ⇒ index filtered được tạo THẬT.
4. **KHÔNG dùng `Remove()` để đo xoá mềm**: `TestApplicationDbContext` không wire `AuditingInterceptor`
   ⇒ `Remove()` = xoá CỨNG (bài học S155). Test set cờ `IsDeleted` bằng tay — đúng thứ cần đo.

---

## §4 — Verdict

**[LEAD điền on-behalf]** PASS — 4/4 test mới xanh trong full-suite **566/0** (lead chạy tươi); fault-inject 2 chiều đã chứng răng (§2); baseline khớp đĩa. Vai chết #53 SAU khi mọi việc thật xong — sub-md ghi-từng-mục cứu trọn lần 2 trong wave này.
