# WQ3-TEST — test nhánh FINALIZE Hợp đồng (YC-030 Q3)

Agent: test-specialist · Run: 2026-08-12-S190-soltuon-dieu-chinh-hd
Ranh: Write CHỈ {ContractApproveFinalizeTests.cs, sub-wq3-test.md}. KHÔNG commit. KHÔNG chạm production code.

File test: `tests/SolutionErp.Infrastructure.Tests/Application/ContractApproveFinalizeTests.cs` (MỚI, 4 `[Fact]`).

## Ca đã viết

Đối tượng đo: `src/Backend/SolutionErp.Infrastructure/Services/ContractWorkflowService.cs:527-535`
(`if (matchingLevel.AllowApproverFinalize && applyLevelFinalize)` → `EndedByLevelFinalize=true` →
`FinalizeToDaPhatHanhAsync:576`). Đây là **test-bù** (mã BE land trước, chưa commit), KHÔNG phải test-before.

**Hình chung của cả 4 ca (điều kiện SỐNG CÒN):** quy trình **2 Bước** — Bước 1 có **2 Cấp**, Bước 2
có 1 Cấp — người bấm Duyệt luôn là **Bước 1 Cấp 1**, tức còn NGUYÊN 2 chặng phía sau.
Bài S164-K3: cắm cờ finalize ở slot CUỐI thì "phát hành + có mã" xảy ra ở CẢ HAI thế giới ⇒ F-1
xanh mà chứng minh 0. Hình này được **chốt bằng assert** `AssertFinalizeSlotIsNotTheLastAsync`
(steps==2 ∧ levels(Bước 1)==2) gọi ngay trước mỗi lượt bấm, để lần "gọn hoá seed" sau này không âm
thầm làm rỗng cả file.

| Ca | Tên `[Fact]` | Biến bật/tắt | Kết quả kỳ vọng |
|---|---|---|---|
| F-1 | `ApproveV2_LevelFinalizeFlag_WithDefaultApply_PublishesContractAndSetsRuntimeFlag` | cờ Cấp `AllowApproverFinalize=true` · **KHÔNG truyền** `applyLevelFinalize` | `Phase=DaPhatHanh` · `EndedByLevelFinalize=true` · `MaHopDong="PFIN1/HĐTP/SOL&FIN1/01"` · con-trỏ Bước/Cấp + SLA `null` |
| F-2 | `ApproveV2_LevelFinalizeFlag_ButApproverOptsOut_AdvancesNormally` | cờ Cấp `true` · `applyLevelFinalize:false` | `Phase=ChoDuyet` · `LevelOrder=2` · `StepIndex=0` · cờ `false` · `MaHopDong=null` |
| F-3 | `ApproveV2_NoLevelFinalizeFlag_EvenWhenApplyRequested_AdvancesNormally` | cờ Cấp `false` · `applyLevelFinalize:true` | như F-2 |
| F-4 | `ApproveV2_AfterLevelFinalize_FurtherApproveThrowsConflict_AndChangesNothing` | finalize xong rồi bấm Duyệt tiếp ×2 người | `ConflictException("*DaPhatHanh*không hỗ trợ*")` ×2 · KHÔNG đổi gì |

### Vì sao ba ca F-1/F-2/F-3 không thay thế nhau (ma trận chứng `&&`)
- F-1 giữ **vế dương** + đo luôn ngữ nghĩa **OPT-OUT** (mặc định `true` ở `IContractWorkflowService.cs:25`)
  — cố ý KHÔNG truyền tham số; truyền tay `true` là bỏ mất vế đó.
- F-2 tắt vế **ý người duyệt**; F-3 tắt vế **cấu hình Cấp**. Cài `||` thay `&&` ⇒ ĐỎ cả hai.
  Bỏ đọc tham số ⇒ chỉ F-2 đỏ. Bỏ đọc cờ cấu hình ⇒ chỉ F-3 đỏ.
- F-3 còn có assert **tiền-đề** đọc lại `AllowApproverFinalize` từ DB — chốt "biến đang tắt đúng là
  biến định tắt", nếu không thì seed sai cờ làm F-3 trùng hệt F-2 mà không ai biết.

### F-4 — assert chọn theo MÃ, không theo mong muốn
Đọc `TransitionAsync` trước khi viết: sau finalize `Phase=DaPhatHanh` ⇒ lượt Duyệt kế tiếp trượt hết
mọi nhánh (reject `:55` / trình `:96` / duyệt `:159` đều đòi phase khác, `isAdmin` false) và rơi
xuống câu cuối `:268` `ConflictException("Transition {from} → {to} không hỗ trợ.")`.
⇒ assert đúng là **409 + không đổi gì**, không phải "im lặng bỏ qua". Hai người bấm, hai lý do đời thực:
(a) chính người vừa chốt bấm lại (double-click), (b) NV Cấp 2 — người bị cắt lượt, màn hình cũ còn nút Duyệt.
Vế "không đổi gì" đo cả `MaHopDong` (không cấp mã lần 2), `ContractApprovals` count==1,
`ContractLevelOpinions` count==1 — một cài đặt "ném lỗi SAU khi đã ghi" vẫn xanh nếu chỉ assert exception.

### 2 quyết định hạ tầng
1. **`IdentityFixture`, KHÔNG dựng fixture riêng có `AuditingInterceptor`** (memory-pack gợi ý mirror
   `ContractDeleteGuardTests.cs:69`). Lý do: service đòi `UserManager<User>` THẬT, còn interceptor chỉ
   phục vụ ô audit/xoá-mềm — file này không đo ô nào trong số đó. Khuôn đúng là file cùng đối tượng đo:
   `Services/ContractWorkflowServiceApproveV2Tests.cs:25` (đã chạy xanh với `ContractCodeGenerator`
   + transaction `Serializable` trên SQLite).
2. Mỗi ca một fixture ⇒ DB SQLite in-memory riêng ⇒ `ContractCodeSequences` không dính nhau, mã HĐ
   mong đợi luôn `/01`. `NoOpNotificationService` dùng lại stub `internal` ở
   `Services/PurchaseEvaluationWorkflowServiceGuardTests.cs:188` (không chép bản thứ hai).

## Kết quả chạy

### (1) Lọc — 4/4 PASS
```
dotnet test tests/SolutionErp.Infrastructure.Tests \
  --filter FullyQualifiedName~ContractApproveFinalizeTests --nologo -p:BuildInParallel=false -maxcpucount:1
Passed!  - Failed: 0, Passed: 4, Skipped: 0, Total: 4, Duration: 3 s
```

### (2) CHỨNG RĂNG — probe VI-PHÂN (sửa TEST, KHÔNG chạm production)
Không được chạm mã BE ⇒ không fault-inject được. Thay bằng: thế-giới-hỏng **tương đương chính xác**
với việc gọi CHÍNH service thật bằng tham số/seed khác ⇒ đổi đúng 1 chữ TRONG TEST, chạy trên mã
production THẬT. Backup file trước, revert sau, `diff` chứng khớp byte.

| Probe (thế giới hỏng mô phỏng) | Đổi | Kết quả |
|---|---|---|
| BE bỏ qua nhánh finalize (luôn advance) | F-1 thêm `applyLevelFinalize:false` | **ĐỎ** — `Expected contract.Phase to be DaPhatHanh {9} … but found ChoDuyet {10}` (`:240`) |
| BE bỏ đọc tham số `applyLevelFinalize` | F-2 bỏ `applyLevelFinalize:false` | **ĐỎ** — `Expected … ChoDuyet {10} … but found DaPhatHanh {9}` (`:302`) |
| BE dùng `\|\|` thay `&&` (ý người duyệt tự cấp quyền) | F-3 seed `allowFinalizeAtS1L1:true` (+ tạm bỏ assert tiền-đề để nhìn vế KẾT QUẢ đỏ) | **ĐỎ** — `Expected … ChoDuyet {10} … but found DaPhatHanh {9}` (`:359`) |

`Failed: 3, Passed: 1` — F-4 giữ xanh đúng như thiết kế (không phụ thuộc 2 biến bị lật).
Revert: `diff backup ↔ file` **RỖNG** (khớp byte) · `grep -c PROBE` = **0**.

🔴 F-4 KHÔNG có probe vi-phân: thế-giới-hỏng của nó ("nhánh duyệt nhận cả HĐ đã phát hành") chỉ dựng
được bằng cách sửa production ⇒ khai thẳng. Răng của F-4 là **nội tại**: `ThrowAsync<ConflictException>`
đỏ nếu không ném, + `WithMessage("*DaPhatHanh*…")` khoá đúng lý do, + count==1 khoá vế "không ghi".

### (3) Full suite — 697/0, khớp đúng dự đoán 693+4
```
dotnet test SolutionErp.slnx --nologo -p:BuildInParallel=false -maxcpucount:1
Passed!  - Failed: 0, Passed:  45, Skipped: 0, Total:  45 - SolutionErp.Domain.Tests.dll        (59 ms)
Passed!  - Failed: 0, Passed: 652, Skipped: 0, Total: 652 - SolutionErp.Infrastructure.Tests.dll (39 s)
                                                     tổng = 45 + 652 = 697   (baseline 693 → Δ+4, 0 hồi quy)
```

### (4) Audit ranh giới (isolation)
```
git status --porcelain | grep -E "tests/|sub-wq3-test"
 M .claude/workflows/runs/2026-08-12-S190-soltuon-dieu-chinh-hd/sub-wq3-test.md
?? tests/SolutionErp.Infrastructure.Tests/Application/ContractApproveFinalizeTests.cs
```
Đúng 2 file. `src/**` dirty là của lane WQ3-BE (có từ trước lượt này), tôi KHÔNG chạm.
KHÔNG commit · KHÔNG ghi `agent-memory/**` (spawn-record trả về cho em main harvest).

## Ghi chú

- **0 bug production phát hiện** — cả 4 ca xanh trên mã BE vừa land, không ca nào phải giữ ĐỎ,
  không ca nào phải nới assert.
- **Spec ⟂ code:** không thấy lệch. Cột `Contract.EndedByLevelFinalize` (`Contract.cs:60`, Mig
  `20260812141501_AddContractEndedByLevelFinalize`) có write-site DUY NHẤT là nhánh finalize —
  F-2/F-3 khoá luôn chiều "không được gắn nhãn kết thúc sớm cho lượt duyệt thường", tức bảo vệ đúng
  lý do cột này tồn tại thay vì suy từ config (bài #78).
- **Vùng KHÔNG phủ ở lane này** (khai để lead không đọc nhầm phạm vi):
  - `ContractsController.cs:71` `ApplyLevelFinalize: body.ApplyLevelFinalize ?? true` — lớp
    controller/DTO; test này gọi service trực tiếp nên chỉ chứng mặc định **ở tầng service**
    (`IContractWorkflowService.cs:25`). Hai chỗ đang khớp nhau, nhưng đó là 2 mặc định riêng biệt.
  - Ca `skipToFinal=true` **cùng lúc** `applyLevelFinalize=true` (comment `:515-521` khai skip thắng
    vì `return` trước). Không viết: nằm ngoài 4 ca được giao, và đã có `ApproveV2_SkipToFinal_*`
    (`ContractWorkflowServiceApproveV2Tests.cs:348`) neo nhánh skip.
  - FE panel (lane WQ3b) — ngoài phạm vi.
