# sub-d2-test-4 — ĐỢT 2: test đường xóa-khi-đang-duyệt + màn "Đã xóa" > Diary GHI TRONG LÚC LÀM (run này đã mất 1 diary 0-byte + 9 return). Flush từng phần, tự `wc -c` sau mỗi lần ghi. ## 0. Trạng thái nhận việc - Baseline lead đo: **551 PASS / 0 FAIL** (Domain 45 + Infra 506). Code đợt 2 đã land, 0 test. - Mục tiêu: 10 test (T21, T22, T22b, T22c, T23, T24, T24b, T25, T25b, T26) ⇒ đích > 551. ## 1. Đọc vật cần test — ĐÃ XONG ### `PeSoftDeleteFeatures.cs` (file mới, 249 dòng) — 2 vật **A. `DeletePurchaseEvaluationByApproverCommandHandler`** (`:50-145`) — thứ tự rào: 1. `!IsAuthenticated || UserId is null` → `UnauthorizedException` 2. PE không tồn tại → `NotFoundException` 3. `Phase != ChoDuyet` → `ConflictException` 4. `ApprovalWorkflowId is not Guid` → `ConflictException` 5. steps rỗng / `CurrentWorkflowStepIndex` ngoài biên → `ConflictException` 6. `pendingLevelGroup.Count == 0` (Bước không có Cấp đang chờ) → `ConflictException` 7. **KHÔNG match `ApproverUserId == actorId`** → `ForbiddenException` ("chưa tới lượt") ← **T24** 8. **`!matchingLevel.AllowApproverDelete`** → `ForbiddenException` ("chưa được bật quyền") ← **T23, T24b** 9. Ghi `PurchaseEvaluationChangelog` (Action=`ChangelogAction.Delete`, EntityType=Workflow, EntityId=`matchingLevel.Id`, PhaseAtChange=ChoDuyet, ContextNote=Reason.Trim() hoặc null) ← **T26** 10. `db.PurchaseEvaluations.Remove(pe)` = XÓA MỀM qua `AuditingInterceptor` (Deleted→Modified + IsDeleted=true) 🔴 Điểm sống-còn cho test: rào (7) đọc **PER-ROW** `pendingLevelGroup.FirstOrDefault(l => l.ApproverUserId == actorId)`, rào (8) đọc `matchingLevel.AllowApproverDelete` — **không** `g.Any(...)`. Đây đúng là thứ **T24b** phải ghim. **B. `ListDeletedPurchaseEvaluationsQueryHandler`** (`:153-248`): - `IgnoreQueryFilters().Where(x => x.IsDeleted)` ← **T22b** ghim vế `.Where` (gỡ nó ⇒ list toàn phiếu sống) - INNER join `db.Projects` (❗ phiếu phải có Project THẬT, không Guid ngẫu nhiên — nếu không row rơi khỏi kết quả và test xanh giả) - IDOR-scope khi `!Roles.Contains(Admin)`: `DrafterUserId == userId` ∥ `eligiblePhases.Contains(Phase)` ∥ (Phase != DangSoanThao ∧ pinned wf ∈ workflow mà user là approver) ← **T22c** ### `PeBudgetAccumulator.cs` — số học cho T21 (🔴 KHÔNG phải "cả 4 cùng giảm") | Đại lượng | Hàm | Điều kiện phase | Phiếu `ChoDuyet` có winner đang xét | |---|---|---|---| | `PrevSubmitted*` | `ComputeAsync :53-59` | `ChoDuyet ∥ DaDuyet` | ✅ CÓ vào ⇒ xóa thì **↓** | | `PrevSelected*` | `ComputeAsync :61-62` | `DaDuyet` ∧ có winner | ❌ không (đòi DaDuyet) ⇒ **KHÔNG đổi** | | `PendingSubmitted*` | `ComputePendingAsync :104-108` | loại tường minh `ChoDuyet` | ❌ không ⇒ **KHÔNG đổi** | | `PendingSelected*` | `ComputePendingAsync :114-117` | ∉{DangSoanThao,DaDuyet,TuChoi} ∧ winner | ✅ CÓ vào ⇒ xóa thì **↓** | | `PriorPes` | `:128-133` | ∉{DaDuyet,TuChoi} | ✅ CÓ ⇒ **mất đúng 1 phần tử** | Cơ chế triệt tiêu: `Remove` ⇒ `IsDeleted=true` ⇒ global filter `PurchaseEvaluationConfiguration.cs:84` loại khỏi `db.PurchaseEvaluations` ⇒ CẢ 4 phép cộng đọc hụt. Accumulator KHÔNG sửa. ### Controller (`PurchaseEvaluationsController.cs`) - `:163-169` `DELETE {id}/by-approver` + `[Authorize(Policy = "PurchaseEvaluations.Delete")]` ← **T25** - `:174-182` `GET deleted` + `[Authorize(Policy = "PurchaseEvaluations.Read")]` - `:151-156` `DELETE {id}` (nháp) — **KHÔNG policy**, đừng nhầm ⇒ T25 phải assert cả vế này (nếu ai đó "sửa" bằng cách gắn policy lên method nháp thì T25 vẫn phải phân biệt được 2 method). ## 2. Kế hoạch file test - File MỚI `tests/SolutionErp.Infrastructure.Tests/Application/PeDeleteByApproverTests.cs` — T21..T24b, T25b, T26 (handler-level, SQLite thật). - T25 (403 policy) = Pattern 10 reflection, thêm vào file mới cùng chỗ (đọc attribute trên **method**, không phải class — 2 endpoint này gắn method-level).