# 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).

<!-- flush 1 -->

## 3. 🔴 PHÁT HIỆN HẠ TẦNG — `AuditingInterceptor` KHÔNG wire trong test fixture

`TestApplicationDbContext` (`Common/SqliteDbFixture.cs:12-32`) chỉ override column type; **không** `AddInterceptors`. Prod wire ở `Infrastructure/DependencyInjection.cs:61-62`. 7 file test hiện có đã ghi chú điều này và **né** bằng cách set `IsDeleted = true` thủ công.

Nhưng đợt này KHÔNG né được: vật cần đo (`handler.Handle`) gọi `db.PurchaseEvaluations.Remove(pe)` và **ý nghĩa của nó do interceptor quyết định**:
- có interceptor ⇒ Deleted→Modified + `IsDeleted=true` ⇒ **row Ở LẠI** ⇒ màn "Đã xóa" có gì để hiện.
- không interceptor ⇒ **XÓA CỨNG** ⇒ T22b/T22c sẽ assert trên list RỖNG = đúng ở cả hai thế giới = **chính cái bẫy assert-rỗng-nghĩa lead cấm**.

⇒ Quyết định: dựng fixture RIÊNG trong file test (`SoftDeleteFixture`, ~55 LOC, mirror `IdentityFixture` + `.AddInterceptors(new AuditingInterceptor(currentUser, clock))`). KHÔNG sửa `IdentityFixture` dùng chung (500+ test đang dựa vào hành vi hiện tại). Đây là `tests/**` = trong phạm vi của tôi, KHÔNG chạm production.

### Rủi ro đã lường TRƯỚC khi chạy — cascade nuốt changelog (ảnh hưởng T26)
`PurchaseEvaluationConfiguration.cs:78`: `HasMany(x => x.Changelogs)...OnDelete(Cascade)` + navigation 2 chiều.
Handler làm đúng thứ tự: `Changelogs.Add(...)` **rồi** `PurchaseEvaluations.Remove(pe)`. EF mặc định `CascadeDeleteTiming.Immediate` ⇒ ngay tại `Remove()` mọi dependent ĐANG TRACKED bị cascade; dependent ở trạng thái `Added` khi bị set Deleted thì EF chuyển thành **Detached** (không phải Deleted).
Nếu đúng vậy ⇒ **changelog bị vứt IM LẶNG ở CẢ PROD**, vì cascade xảy ra ở `Remove()` (trước), còn interceptor chỉ chạy ở `SavingChanges` (sau). T26 sẽ ĐỎ và đó là **bug thật**, không phải lỗi harness.
Chưa kết luận — để test trả lời. Ghi trước ở đây để nếu tôi mất return thì lead vẫn có giả thuyết.

## 4. Thiết kế 10 ca — mỗi ca kèm CHỨNG-NHÂN (gỡ tính năng ⇒ đỏ)

Seed chung T21/T22: 3 peer cùng cặp (Project, WorkItem), đều TRƯỚC mốc `CurCreatedAt`:
| Peer | Phase | BudgetPeriod | winner quote |
|---|---|---|---|
| `peerDaDuyet` | DaDuyet | 100tr | 90tr |
| `peerTraLai` | TraLai | 60tr | 20tr |
| **`target`** (bị xóa) | **ChoDuyet** | **30tr** | **50tr** |

Số TRƯỚC → SAU (exact, không chỉ "giảm"):
- `PrevSubmitted` 130tr/2 → **100tr/1** (↓ đúng 30tr) — `ComputeAsync:53-59` nhận ChoDuyet
- `PrevSelected` 90tr/1 → **90tr/1** (KHÔNG đổi, **≠ 0** nên không vacuous) — `:61-62` đòi DaDuyet
- `PendingSubmitted` 60tr/1 → **60tr/1** (KHÔNG đổi, **≠ 0**) — `:107` loại tường minh ChoDuyet
- `PendingSelected` 70tr/2 → **20tr/1** (↓ đúng 50tr, **≠ 0** sau khi giảm) — `:114-126` nhận ChoDuyet+winner
- `PriorPes` 2 → **1** (mất đúng `target`, còn `peerTraLai`)

🔴 Chủ ý: 2 đại lượng "KHÔNG đổi" đều được giữ **khác 0 ở cả hai phía** nhờ peer DaDuyet + peer TraLai. Nếu chỉ seed mỗi phiếu bị xóa thì "không đổi" = 0→0 = vô nghĩa.

- **T22**: gọi handler `/deleted` (dùng `IgnoreQueryFilters`) XONG rồi tính lại accumulator ⇒ 4 số y hệt sau-xóa. Chứng-nhân: list phải THẬT SỰ trả về phiếu đã xóa (assert chứa `target`), nếu không thì "không rò rỉ" là vì list rỗng.
- **T22b**: 1 phiếu đã xóa + **2 phiếu SỐNG** (Project thật vì handler INNER JOIN Projects) ⇒ list đúng 1 phần tử. Chứng-nhân: gỡ `.Where(x => x.IsDeleted)` ⇒ 3 phần tử ⇒ đỏ. Thêm đối chứng: 2 phiếu sống ĐANG thấy được ở list thường.
- **T22c**: `dave` (0 role, không drafter, không approver quy trình đã pin) ⇒ 0. Chứng-nhân: **cùng seed, cùng query**, đổi mỗi actor sang drafter ⇒ 1. Nếu bỏ khối IDOR ⇒ dave thấy 1 ⇒ đỏ.
- **T23**: alice đúng lượt, cờ **false** ⇒ Forbidden + phiếu **vẫn sống**.
- **T24**: Cấp 1 = alice, Cấp 2 = carol (**cờ carol = true**), con trỏ ở Cấp 1, carol xóa ⇒ Forbidden "chưa tới lượt" + phiếu vẫn sống. Cờ true để ca này đo ĐÚNG biến "lượt".
- **T24b**: Cấp 1 có **2 người**: alice(cờ TRUE) + bob(cờ FALSE), cùng đúng lượt. bob TRƯỚC ⇒ Forbidden; alice SAU ⇒ xóa được. `g.Any(...)` ⇒ bob qua ⇒ đỏ.
- **T25**: reflection method-level `DeleteByApprover` = `PurchaseEvaluations.Delete` + tên policy có thật trong `MenuKeys.All`/`Actions` + `Delete` (nháp) **KHÔNG** policy (giữ ranh giới 2 đường).
- **T25b**: alice đủ cả 3 ⇒ không ném + row còn + `IsDeleted=true` + `DeletedBy=alice` + biến mất khỏi query thường + hiện ở list "Đã xóa".
- **T26**: 1 changelog `Action=Delete`, `EntityId=matchingLevel.Id`, `PhaseAtChange=ChoDuyet`, `ContextNote=lý do`; đối chứng phiếu thứ 2 xóa KHÔNG lý do ⇒ `ContextNote=null`.

<!-- flush 2 -->

## 5. KẾT QUẢ chạy lọc (10 ca mới): **9 PASS / 1 FAIL**

```
Failed!  - Failed: 1, Passed: 9, Skipped: 0, Total: 10 - SolutionErp.Infrastructure.Tests.dll
T26_Delete_WritesChangelogWithDeleteAction_AndCarriesReasonWhenProvided [FAIL]
  Expected logs to contain 2 item(s) ..., but found 0: {empty}.
```

## 6. 🔴🔴 BUG PRODUCTION — vết xóa (changelog + LÝ DO) KHÔNG BAO GIỜ được ghi

Giả thuyết ở mục 3 **đúng**. Không suy đoán — đã đo bằng chẩn đoán ChangeTracker riêng (file tạm, đã xóa sau khi đọc):

```
stateAfterAdd=Added | navFixedUp=True | parentColl=1 | cascadeTiming=Immediate
| stateAfterRemove=Detached | rowsInDb=0
```

Diễn giải từng bước, khớp đúng mã:
1. `PeSoftDeleteFeatures.cs:126` `Changelogs.Add(...)` ⇒ state **Added**; EF fixup tự gắn nó vào `pe.Changelogs` (navigation 2 chiều `PurchaseEvaluationConfiguration.cs:78`, `OnDelete(Cascade)`) — `parentColl=1` chứng minh.
2. `PeSoftDeleteFeatures.cs:142` `Remove(pe)` ⇒ `CascadeDeleteTiming.Immediate` (mặc định) cascade **ngay tại dòng này**; dependent đang `Added` bị cascade ⇒ EF chuyển thành **Detached** (xóa entity Added = gỡ theo dõi, không phải Deleted).
3. `:143` `SaveChanges` ⇒ **0 row**. `AuditingInterceptor` chạy ở `SavingChanges` = **SAU** điểm mất mát ⇒ việc nó đổi Deleted→Modified không cứu được vết.

**Prod dính y hệt.** Khác biệt duy nhất giữa harness của tôi và prod là interceptor, mà interceptor nằm SAU chỗ mất. Bằng chứng gián tiếp trong chính suite này: T25b xanh ⇒ xóa mềm chạy đúng, `DeletedBy`/`DeletedAt` đúng — **chỉ LÝ DO bị nuốt IM LẶNG**. Đúng lớp lỗi mà spec §B4 dựng ra để chống.

**Grep cùng lớp (bài học root-cause):** đây là site DUY NHẤT trong `src/Backend` mang hình "Add con → Remove cha → 1 SaveChanges". Đường xóa nháp cũ `PurchaseEvaluationFeatures.cs:1408` không ghi changelog nên không dính. Các file khác có `Changelogs.Add` đều xóa CON (supplier/detail) chứ không xóa PE ⇒ nguyên tắc (PE) sống ⇒ không cascade.

**KHÔNG tự sửa** (production = ngoài phạm vi). Giữ T26 ĐỎ + khai, không nới assert. Hướng vá để lead quyết (đều nằm trong `PeSoftDeleteFeatures.cs`): (a) `SaveChanges` cho changelog trước rồi Remove (2 lượt ghi, cần transaction bao ngoài); (b) đặt `CascadeDeleteTiming.Never` quanh lời gọi; (c) không dùng `Remove()` mà set thẳng `IsDeleted/DeletedAt/DeletedBy`. Tôi không chọn hộ.

<!-- flush 3 -->

## 7. SỐ THẬT — full suite `dotnet test SolutionErp.slnx --nologo -v minimal`

```
Passed!  - Failed: 0, Passed:  45, Skipped: 0, Total:  45 - SolutionErp.Domain.Tests.dll
Failed!  - Failed: 1, Passed: 515, Skipped: 0, Total: 516 - SolutionErp.Infrastructure.Tests.dll
```

| | Domain | Infra | Tổng |
|---|---|---|---|
| Baseline (lead đo) | 45 | 506 | **551** pass |
| Sau đợt này | 45 | 516 | **561** total — 560 pass / **1 fail** |

- **+10 test** (đúng 10 ca được giao), tất cả nằm trong 1 file mới.
- **0 hồi quy:** Infra passing 515 − 9 ca mới xanh = **506** = đúng con số Infra cũ ⇒ không ca cũ nào bị tôi làm đỏ.
- 1 FAIL duy nhất = **T26**, giữ đỏ có chủ ý (bug production ở mục 6), assert KHÔNG bị nới.

## 8. Sản phẩm
- File MỚI (duy nhất): `tests/SolutionErp.Infrastructure.Tests/Application/PeDeleteByApproverTests.cs`
- File tạm dùng để chẩn đoán ChangeTracker: đã **XÓA** sau khi đọc kết quả (kiểm lại: 0 file `Zz*` còn sót).
- KHÔNG chạm `src/Backend`, KHÔNG chạm `fe-*`, KHÔNG sửa `IdentityFixture`/`SqliteDbFixture` dùng chung.

## 9. Ghi chú bàn giao
- `SoftDeleteFixture` (trong chính file test, ~90 LOC) là fixture ĐẦU TIÊN của repo wire `AuditingInterceptor`. Bất kỳ test tương lai nào đo hành vi `Remove()` = xóa mềm đều nên dùng khuôn này thay vì set `IsDeleted` thủ công.
- T24b là ca duy nhất phân biệt được `matchingLevel.AllowApproverDelete` (đúng) với `g.Any(...)` (sai). Nếu ai đó "gọn hoá" chỗ đó, T24b đỏ ngay.
- T22c dùng 2 đối chứng (drafter + approver) chứ không 1 — để số 0 của người ngoài không thể là hệ quả của dữ liệu rỗng.

<!-- END sub-d2-test-4 -->



