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

14 KiB
Raw Blame History

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 nullUnauthorizedException
  2. PE không tồn tại → NotFoundException
  3. Phase != ChoDuyetConflictException
  4. ApprovalWorkflowId is not GuidConflictException
  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 == actorIdForbiddenException ("chưa tới lượt") ← T24
  8. !matchingLevel.AllowApproverDeleteForbiddenException ("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.AllowApproverDeletekhô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 == userIdeligiblePhases.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: RemoveIsDeleted=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).

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à 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=truerow Ở 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.

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 SaveChanges0 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ộ.

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.