14 KiB
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 -csau 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:
!IsAuthenticated || UserId is null→UnauthorizedException- PE không tồn tại →
NotFoundException Phase != ChoDuyet→ConflictExceptionApprovalWorkflowId is not Guid→ConflictException- steps rỗng /
CurrentWorkflowStepIndexngoài biên →ConflictException pendingLevelGroup.Count == 0(Bước không có Cấp đang chờ) →ConflictException- KHÔNG match
ApproverUserId == actorId→ForbiddenException("chưa tới lượt") ← T24 !matchingLevel.AllowApproverDelete→ForbiddenException("chưa được bật quyền") ← T23, T24b- Ghi
PurchaseEvaluationChangelog(Action=ChangelogAction.Delete, EntityType=Workflow, EntityId=matchingLevel.Id, PhaseAtChange=ChoDuyet, ContextNote=Reason.Trim() hoặc null) ← T26 db.PurchaseEvaluations.Remove(pe)= XÓA MỀM quaAuditingInterceptor(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-169DELETE {id}/by-approver+[Authorize(Policy = "PurchaseEvaluations.Delete")]← T25:174-182GET deleted+[Authorize(Policy = "PurchaseEvaluations.Read")]:151-156DELETE {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).
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"):
PrevSubmitted130tr/2 → 100tr/1 (↓ đúng 30tr) —ComputeAsync:53-59nhận ChoDuyetPrevSelected90tr/1 → 90tr/1 (KHÔNG đổi, ≠ 0 nên không vacuous) —:61-62đòi DaDuyetPendingSubmitted60tr/1 → 60tr/1 (KHÔNG đổi, ≠ 0) —:107loại tường minh ChoDuyetPendingSelected70tr/2 → 20tr/1 (↓ đúng 50tr, ≠ 0 sau khi giảm) —:114-126nhận ChoDuyet+winnerPriorPes2 → 1 (mất đúngtarget, cònpeerTraLai)
🔴 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ùngIgnoreQueryFilters) 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ứatarget), 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 trongMenuKeys.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.
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ã:
PeSoftDeleteFeatures.cs:126Changelogs.Add(...)⇒ state Added; EF fixup tự gắn nó vàope.Changelogs(navigation 2 chiềuPurchaseEvaluationConfiguration.cs:78,OnDelete(Cascade)) —parentColl=1chứng minh.PeSoftDeleteFeatures.cs:142Remove(pe)⇒CascadeDeleteTiming.Immediate(mặc định) cascade ngay tại dòng này; dependent đangAddedbị cascade ⇒ EF chuyển thành Detached (xóa entity Added = gỡ theo dõi, không phải Deleted).:143SaveChanges⇒ 0 row.AuditingInterceptorchạ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ộ.
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ạmfe-*, KHÔNG sửaIdentityFixture/SqliteDbFixturedùng chung.
9. Ghi chú bàn giao
SoftDeleteFixture(trong chính file test, ~90 LOC) là fixture ĐẦU TIÊN của repo wireAuditingInterceptor. Bất kỳ test tương lai nào đo hành viRemove()= xóa mềm đều nên dùng khuôn này thay vì setIsDeletedthủ công.- T24b là ca duy nhất phân biệt được
matchingLevel.AllowApproverDelete(đúng) vớig.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.