Files
solution-erp/.claude/workflows/runs/2026-07-27-S155-pe-delete-approver/sub-d2-test-4.md
2026-07-27 13:30:58 +07:00

159 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)`**ý 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 -->