[CLAUDE] PurchaseEvaluation: nut XOA phieu o man duyet + menu 3 muc + man "Da xoa" (dot 2)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m47s

Dong ca UAT goc (Tra Sol): "lo bam sai cai goi thau, quay lai khong duoc, phai
xoa thi no moi ko co luy ke len". Dot 1 (b1bae77) da dung NEN (co F6 + lenh sua
quy trinh tai cho); dot 2 la phan nguoi dung cham vao.

- App: PeSoftDeleteFeatures.cs MOI
  - DELETE /api/purchase-evaluations/{id}/by-approver — 3 rao: phase ChoDuyet
    ^ dung luot actor ^ matchingLevel.AllowApproverDelete (PER-NGUOI, khong g.Any)
  - GET /api/purchase-evaluations/deleted — IgnoreQueryFilters() KEM .Where(IsDeleted)
    (IgnoreQueryFilters GO filter chu khong DAO — thieu Where la liet ke ca phieu song)
    + tai lap khoi IDOR cua list hien hanh
  - ghi vet ChangelogAction.Delete + ly do
- Api: 2 endpoint moi. 🔴 by-approver CO Y KHONG co [Authorize(Policy)] — do tren DB:
  moi vai dang lam nguoi duyet (Procurement/CostControl...) deu CanDelete=0 va khong
  seeder nao nang co do => gan policy la 403 voi dung nguoi can dung, ma FE gate cung
  quyen nen khong thay nut khong thay loi (gotcha #44). Quyen = 3 rao handler; co F6
  la admin tick dich danh tung nguoi => chat hon quyen theo vai.
- Dto: ApprovalWorkflowOptionsDto + AllowApproverDelete. Thieu field nay thi FE gate
  luon sai => nut KHONG BAO GIO hien, ma BE lan FE deu build sach (reviewer bat).
- Domain/Infra: 2 menu key Pe_{code}_Approved / _Deleted (KHONG vao MenuKeys.All =>
  giu 54/216) + seed tree + labelBackfill "Duyet" -> "Dang duyet" + 2 danh sach permission
- FE x2 app: nut Xoa trong PeWorkflowPanel (md5 mirror khop) + dialog ly do + Layout
  regex/route cho 2 muc moi + man "Da xoa" chi-xem
- Tests: +11 (562 tong, 0 fail). T27 khoa CA LOP loi entity-co-co-ma-DTO-khong-co
  (fault-inject xac nhan: go field ra => T27 do ngay)

Review: 12 FLAG, chot chan H1 (DTO thieu field) da va. Deploy KHONG co migration
nhung CO seed menu => phai restart API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-27 14:06:14 +07:00
parent 0aa8557650
commit 4464a464c5
15 changed files with 1633 additions and 33 deletions

View File

@ -0,0 +1,822 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Api.Controllers; // Pattern 10 — reflection authz (T25)
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.PurchaseEvaluations;
using SolutionErp.Application.PurchaseEvaluations.Dtos; // ApprovalWorkflowOptionsDto (T27)
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Contracts; // ChangelogAction (dùng chung PE)
using SolutionErp.Domain.Identity; // AppRoles + MenuKeys
using SolutionErp.Domain.Master;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Persistence.Interceptors;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// =============================================================================================
// S155 ĐỢT 2 (2026-07-27) — XÓA MỀM phiếu khi ĐANG DUYỆT + màn "Đã xóa".
//
// Vật đo: `src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSoftDeleteFeatures.cs`
// A. `DeletePurchaseEvaluationByApproverCommandHandler` (:50-145)
// B. `ListDeletedPurchaseEvaluationsQueryHandler` (:153-248)
// + `PurchaseEvaluationsController` :163 (`by-approver`) / :174 (`deleted`) — Pattern 10.
//
// 🔴 VÌ SAO FILE NÀY DỰNG FIXTURE RIÊNG (`SoftDeleteFixture`) THAY VÌ DÙNG `IdentityFixture`
// `TestApplicationDbContext` KHÔNG wire `AuditingInterceptor` (`Common/SqliteDbFixture.cs:12-32`;
// prod wire ở `Infrastructure/DependencyInjection.cs:61-62`). 7 file test hiện có né bằng cách
// set `IsDeleted = true` THỦ CÔNG. Đợt này KHÔNG né được: thứ cần đo là chính lời gọi
// `db.PurchaseEvaluations.Remove(pe)` — mà Ý 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 ⇒ mọi assert của T22b/T22c chạy trên list RỖNG ⇒ đúng ở CẢ HAI
// thế giới ⇒ đo được số không. Đó đúng là bẫy "assert rỗng nghĩa".
// ⇒ fixture này = bản sao `IdentityFixture` + `.AddInterceptors(new AuditingInterceptor(...))`.
// KHÔNG sửa `IdentityFixture` dùng chung (500+ test đang dựa vào hành vi hiện tại của nó).
//
// Test theo CODE (single source of truth). Test-after: code đợt 2 đã land. KHÔNG chạm production.
// =============================================================================================
public class PeDeleteByApproverTests
{
// Mốc thời gian: peer seed TRƯỚC `CurCreatedAt`; accumulator lấy window `CreatedAt < CurCreatedAt`.
private static readonly DateTime BaseT = new(2026, 7, 27, 8, 0, 0, DateTimeKind.Utc);
private static readonly DateTime CurCreatedAt = new(2026, 7, 27, 12, 0, 0, DateTimeKind.Utc);
// ============================ HẠ TẦNG ============================
/// Bản sao `IdentityFixture` + AuditingInterceptor để `Remove()` = XÓA MỀM đúng như prod.
/// `Actor` mutable — đổi người thao tác giữa 2 lời gọi trong cùng test (T24b: bob rồi alice).
private sealed class SoftDeleteFixture : IDisposable
{
private readonly SqliteConnection _conn;
private readonly ServiceProvider _root;
public IServiceProvider Services { get; }
public TestApplicationDbContext Db { get; }
public TestCurrentUser Actor { get; } = new();
public FixedDateTime Clock { get; } = new(new DateTime(2026, 7, 27, 3, 0, 0, DateTimeKind.Utc));
public SoftDeleteFixture()
{
_conn = new SqliteConnection("DataSource=:memory:");
_conn.Open();
var services = new ServiceCollection();
services.AddLogging();
var conn = _conn;
var actor = Actor;
var clock = Clock;
services.AddScoped<ApplicationDbContext>(_ =>
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(conn)
.EnableSensitiveDataLogging()
.AddInterceptors(new AuditingInterceptor(actor, clock)) // 🔴 khác IdentityFixture
.Options;
return new TestApplicationDbContext(options);
});
services.AddScoped<TestApplicationDbContext>(sp =>
(TestApplicationDbContext)sp.GetRequiredService<ApplicationDbContext>());
services.AddScoped<IApplicationDbContext>(sp =>
sp.GetRequiredService<TestApplicationDbContext>());
services.AddIdentityCore<User>(opt =>
{
opt.Password.RequireDigit = false;
opt.Password.RequireLowercase = false;
opt.Password.RequireUppercase = false;
opt.Password.RequireNonAlphanumeric = false;
opt.Password.RequiredLength = 4;
})
.AddRoles<Role>()
.AddEntityFrameworkStores<ApplicationDbContext>();
_root = services.BuildServiceProvider();
Services = _root.CreateScope().ServiceProvider;
Db = Services.GetRequiredService<TestApplicationDbContext>();
Db.Database.EnsureCreated();
}
public async Task<User> CreateUserAsync(string email, string fullName, params string[] roles)
{
var um = Services.GetRequiredService<UserManager<User>>();
var rm = Services.GetRequiredService<RoleManager<Role>>();
foreach (var role in roles)
if (!await rm.RoleExistsAsync(role))
await rm.CreateAsync(new Role { Id = Guid.NewGuid(), Name = role });
var user = new User
{
Id = Guid.NewGuid(),
UserName = email,
Email = email,
EmailConfirmed = true,
FullName = fullName,
IsActive = true,
};
var created = await um.CreateAsync(user, "Test@123");
if (!created.Succeeded)
throw new InvalidOperationException("CreateUserAsync failed: "
+ string.Join(",", created.Errors.Select(e => e.Description)));
if (roles.Length > 0) await um.AddToRolesAsync(user, roles);
return user;
}
/// Đặt người đang thao tác cho CẢ handler LẪN interceptor (DeletedBy đọc từ đây).
public TestCurrentUser ActAs(User u, params string[] roles)
{
Actor.UserId = u.Id;
Actor.FullName = u.FullName;
Actor.Email = u.Email;
Actor.Roles = roles;
return Actor;
}
public void Dispose()
{
_root.Dispose();
_conn.Dispose();
}
}
private static DeletePurchaseEvaluationByApproverCommandHandler NewDeleteHandler(SoftDeleteFixture f)
=> new(f.Db, f.Actor);
private static ListDeletedPurchaseEvaluationsQueryHandler NewDeletedListHandler(
SoftDeleteFixture f, ICurrentUser who) => new(f.Db, who);
// ---- seed helpers (mirror PePendingAccumulationTests :58-142) ----
private static async Task<(Project project, WorkItem wi)> SeedProjectAndWorkItemAsync(
TestApplicationDbContext db, string suffix)
{
var project = new Project { Id = Guid.NewGuid(), Code = "PRJ-DEL-" + suffix, Name = "Dự án " + suffix };
var wi = new WorkItem { Id = Guid.NewGuid(), Code = "WI-DEL-" + suffix, Name = "Hạng mục " + suffix, IsActive = true };
db.Projects.Add(project);
db.WorkItems.Add(wi);
await db.SaveChangesAsync(CancellationToken.None);
return (project, wi);
}
/// 1 đơn vị IsWinner + 1 hạng mục + 1 báo giá IsSelected (nuôi PrevSelected/PendingSelected).
private static async Task SeedWinnerQuoteAsync(
TestApplicationDbContext db, PurchaseEvaluation pe, decimal thanhTien)
{
var sup = new PurchaseEvaluationSupplier
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = pe.Id,
SupplierId = Guid.NewGuid(),
Order = 0,
IsWinner = true,
};
var detail = new PurchaseEvaluationDetail
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = pe.Id,
GroupCode = "A.I",
GroupName = "Bê tông",
NoiDung = "Concrete",
Order = 0,
};
db.PurchaseEvaluationSuppliers.Add(sup);
db.PurchaseEvaluationDetails.Add(detail);
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
{
Id = Guid.NewGuid(),
PurchaseEvaluationDetailId = detail.Id,
PurchaseEvaluationSupplierId = sup.Id,
ThanhTien = thanhTien,
IsSelected = true,
});
await db.SaveChangesAsync(CancellationToken.None);
}
private static async Task<PurchaseEvaluation> SeedPeAsync(
TestApplicationDbContext db,
Guid projectId, Guid? workItemId, Guid drafterUserId, string code,
PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.ChoDuyet,
Guid? approvalWorkflowId = null,
int stepIdx = 0, int levelOrder = 1,
decimal? budgetPeriod = null, decimal? winnerQuote = null,
DateTime? createdAt = null)
{
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = phase,
MaPhieu = code,
TenGoiThau = "Gói thầu " + code,
ProjectId = projectId,
WorkItemId = workItemId,
DrafterUserId = drafterUserId,
ApprovalWorkflowId = approvalWorkflowId,
CurrentWorkflowStepIndex = approvalWorkflowId is null ? null : stepIdx,
CurrentApprovalLevelOrder = approvalWorkflowId is null ? null : levelOrder,
BudgetPeriodAmount = budgetPeriod,
};
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
// Interceptor đóng dấu CreatedAt=clock lúc Add ⇒ ghi đè SAU (lượt 2 = Modified, chỉ đụng UpdatedAt).
pe.CreatedAt = createdAt ?? BaseT;
await db.SaveChangesAsync(CancellationToken.None);
if (winnerQuote is decimal wq) await SeedWinnerQuoteAsync(db, pe, wq);
return pe;
}
/// stepSpecs[s] = các dòng Cấp của Bước (Order s+1): (LevelOrder, ApproverUserId, AllowApproverDelete).
/// N dòng cùng LevelOrder = 1 Cấp N người (OR-of-N) — đúng hình dạng prod (ảnh UAT Cấp 2 có 3 người).
private static async Task<ApprovalWorkflow> SeedWorkflowAsync(
TestApplicationDbContext db,
(int LevelOrder, Guid User, bool AllowDelete)[][] stepSpecs,
string code = "QT-DEL-V2")
{
var wf = new ApprovalWorkflow
{
Code = code,
Version = 1,
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
Name = "QT test xóa khi đang duyệt",
IsActive = true,
IsUserSelectable = true,
};
for (int s = 0; s < stepSpecs.Length; s++)
{
var step = new ApprovalWorkflowStep
{
ApprovalWorkflowId = wf.Id,
Order = s + 1,
Name = $"Bước {s + 1}",
};
foreach (var (levelOrder, user, allowDelete) in stepSpecs[s])
step.Levels.Add(new ApprovalWorkflowLevel
{
ApprovalWorkflowStepId = step.Id,
Order = levelOrder,
Name = $"Cấp {levelOrder}",
ApproverUserId = user,
AllowApproverDelete = allowDelete,
});
wf.Steps.Add(step);
}
db.ApprovalWorkflows.Add(wf);
await db.SaveChangesAsync(CancellationToken.None);
return wf;
}
private static Task<PeBudgetAccumulation> ExactAsync(TestApplicationDbContext db, Guid projectId, Guid wiId)
=> PeBudgetAccumulator.ComputeAsync(
db, projectId, wiId, Guid.NewGuid(), CurCreatedAt, Array.Empty<Guid>(), CancellationToken.None);
private static Task<PePendingAccumulation> PendingAsync(TestApplicationDbContext db, Guid projectId, Guid wiId)
=> PeBudgetAccumulator.ComputePendingAsync(
db, projectId, wiId, Guid.NewGuid(), CurCreatedAt, CancellationToken.None);
/// Nền chung T21/T22: 3 phiếu cùng cặp (Dự án × Hạng mục), tất cả TRƯỚC `CurCreatedAt`.
/// 🔴 CHỦ Ý: peer DaDuyet + peer TraLai tồn tại để 2 đại lượng "KHÔNG đổi" giữ giá trị ≠ 0 ở
/// CẢ HAI phía. Nếu chỉ seed mỗi phiếu bị xóa thì "không đổi" hóa ra 0 → 0 = không đo gì.
private static async Task<(Project project, WorkItem wi, PurchaseEvaluation target, User approver)>
SeedAccumulationSceneAsync(SoftDeleteFixture f, string suffix)
{
var db = f.Db;
var (project, wi) = await SeedProjectAndWorkItemAsync(db, suffix);
var alice = await f.CreateUserAsync($"alice.{suffix}@t.vn", "Trần Thị An");
var drafter = await f.CreateUserAsync($"drafter.{suffix}@t.vn", "Người soạn", AppRoles.Drafter);
// (1) DaDuyet — nuôi PrevSubmitted (100tr) + PrevSelected (90tr). KHÔNG bị đụng khi xóa target.
await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, $"PE-{suffix}-DD",
phase: PurchaseEvaluationPhase.DaDuyet, budgetPeriod: 100_000_000m,
winnerQuote: 90_000_000m, createdAt: BaseT.AddDays(-3));
// (2) TraLai — nuôi PendingSubmitted (60tr) + một phần PendingSelected (20tr).
await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, $"PE-{suffix}-TL",
phase: PurchaseEvaluationPhase.TraLai, budgetPeriod: 60_000_000m,
winnerQuote: 20_000_000m, createdAt: BaseT.AddDays(-2));
// (3) TARGET ChoDuyet — vào PrevSubmitted (30tr) + PendingSelected (50tr). Đây là phiếu bị xóa.
var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, true)]], "QT-DEL-" + suffix);
var target = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, $"PE-{suffix}-TARGET",
phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id,
budgetPeriod: 30_000_000m, winnerQuote: 50_000_000m, createdAt: BaseT.AddDays(-1));
return (project, wi, target, alice);
}
// =====================================================================
// T21 ⭐ LŨY KẾ — xóa phiếu ChoDuyet qua `/by-approver` ⇒ 4 đại lượng đi 4 HƯỚNG KHÁC NHAU.
//
// 🔴 KHÔNG phải "cả 4 cùng giảm". Số học từ code:
// PrevSubmitted ↓ (`PeBudgetAccumulator.cs:53-59` gom ChoDuyet ∥ DaDuyet)
// PrevSelected = (`:61-62` đòi DaDuyet ⇒ phiếu ChoDuyet chưa từng góp mặt)
// PendingSubmitted = (`:107` loại TƯỜNG MINH ChoDuyet để tránh double-count)
// PendingSelected ↓ (`:114-126` nhận ChoDuyet-có-winner — đúng gap UAT)
// PriorPes mất đúng 1 phần tử (`:128-133`)
// Cơ chế: `Remove` → IsDeleted=true → global filter `PurchaseEvaluationConfiguration.cs:84`
// loại phiếu khỏi CẢ 4 phép cộng. Accumulator KHÔNG bị sửa dòng nào.
// =====================================================================
[Fact]
public async Task T21_DeleteByApprover_ChoDuyetPe_MovesFourAccumulatorsInFourDirections()
{
using var f = new SoftDeleteFixture();
var (project, wi, target, alice) = await SeedAccumulationSceneAsync(f, "T21");
// ---------- TRƯỚC ----------
var exactBefore = await ExactAsync(f.Db, project.Id, wi.Id);
var pendBefore = await PendingAsync(f.Db, project.Id, wi.Id);
exactBefore.PrevSubmittedTotal.Should().Be(130_000_000m, "ChoDuyet 30tr + DaDuyet 100tr");
exactBefore.PrevSubmittedCount.Should().Be(2);
exactBefore.PrevSelectedTotal.Should().Be(90_000_000m, "chỉ DaDuyet+winner");
exactBefore.PrevSelectedCount.Should().Be(1);
pendBefore.PendingSubmittedTotal.Should().Be(60_000_000m, "chỉ TraLai (ChoDuyet/DaDuyet bị loại)");
pendBefore.PendingSubmittedCount.Should().Be(1);
pendBefore.PendingSelectedTotal.Should().Be(70_000_000m, "TraLai 20tr + ChoDuyet 50tr");
pendBefore.PendingSelectedCount.Should().Be(2);
pendBefore.PriorPes.Select(p => p.Id).Should().Contain(target.Id,
"tiền đề: phiếu sắp xóa PHẢI đang nằm trong danh sách phiếu trước cần lưu ý");
var priorCountBefore = pendBefore.PriorPes.Count;
priorCountBefore.Should().Be(2, "TraLai + ChoDuyet (DaDuyet là terminal nên không tính)");
// ---------- ACT: đúng người, đúng lượt, đúng cờ ----------
f.ActAs(alice);
await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(target.Id, "Bấm sai gói thầu"), CancellationToken.None);
// Neo chống-vacuous: phải là XÓA MỀM thật (row còn, cờ bật) — không phải xóa cứng.
var row = await f.Db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
.SingleAsync(p => p.Id == target.Id);
row.IsDeleted.Should().BeTrue("`Remove` phải đi qua AuditingInterceptor thành xóa MỀM");
// ---------- SAU ----------
var exactAfter = await ExactAsync(f.Db, project.Id, wi.Id);
var pendAfter = await PendingAsync(f.Db, project.Id, wi.Id);
// ↓ giảm ĐÚNG phần đóng góp của phiếu bị xóa, không hơn không kém.
exactAfter.PrevSubmittedTotal.Should().Be(100_000_000m,
"PrevSubmitted phải GIẢM đúng 30tr = BudgetPeriodAmount của phiếu vừa xóa");
exactAfter.PrevSubmittedCount.Should().Be(1);
pendAfter.PendingSelectedTotal.Should().Be(20_000_000m,
"PendingSelected phải GIẢM đúng 50tr = báo giá đã chọn của phiếu vừa xóa");
pendAfter.PendingSelectedCount.Should().Be(1);
// = BẤT ĐỘNG, và cả hai đều ≠ 0 nên phép đo có nội dung.
exactAfter.PrevSelectedTotal.Should().Be(90_000_000m,
"PrevSelected đòi phase DaDuyet ⇒ phiếu ChoDuyet chưa bao giờ nằm trong đó ⇒ KHÔNG đổi");
exactAfter.PrevSelectedCount.Should().Be(1);
pendAfter.PendingSubmittedTotal.Should().Be(60_000_000m,
"PendingSubmitted loại TƯỜNG MINH ChoDuyet (PeBudgetAccumulator.cs:107) ⇒ KHÔNG đổi");
pendAfter.PendingSubmittedCount.Should().Be(1);
// PriorPes: mất ĐÚNG một phần tử, và đúng phần tử vừa xóa.
pendAfter.PriorPes.Should().HaveCount(priorCountBefore - 1, "mất đúng 1 phần tử");
pendAfter.PriorPes.Should().NotContain(p => p.Id == target.Id);
pendAfter.PriorPes.Should().ContainSingle().Which.MaPhieu.Should().Be("PE-T21-TL",
"phần tử còn lại phải là phiếu TraLai — không phải mất nhầm phiếu khác");
}
// =====================================================================
// T22 — gọi màn "Đã xóa" (`IgnoreQueryFilters`) KHÔNG làm phiếu đã xóa lọt NGƯỢC vào lũy kế.
// Ranh giới D3: `IgnoreQueryFilters` chỉ được sống trong CHÍNH query đó. Nếu ai đó gom nó
// vào helper trả `IQueryable` dùng chung / thêm cờ `includeDeleted` lan sang accumulator thì
// 4 con số quay lại giá trị TRƯỚC-XÓA ⇒ ca này đỏ.
// Chứng-nhân: list "Đã xóa" phải THẬT SỰ trả về phiếu đó — nếu list rỗng thì "không rò rỉ"
// chỉ là hệ quả của việc không có gì để rò.
// =====================================================================
[Fact]
public async Task T22_ListingDeletedScreen_DoesNotLeakDeletedPeBackIntoAccumulator()
{
using var f = new SoftDeleteFixture();
var (project, wi, target, alice) = await SeedAccumulationSceneAsync(f, "T22");
f.ActAs(alice);
await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(target.Id), CancellationToken.None);
var admin = new TestCurrentUser(Guid.NewGuid(), "Quản trị", null, AppRoles.Admin);
var deleted = await NewDeletedListHandler(f, admin)
.Handle(new ListDeletedPurchaseEvaluationsQuery(), CancellationToken.None);
deleted.Items.Select(i => i.Id).Should().Contain(target.Id,
"chứng-nhân: màn Đã xóa PHẢI thấy phiếu vừa xóa, nếu không thì assert dưới rỗng nghĩa");
// Ngay SAU khi query bỏ-lọc chạy, lũy kế vẫn phải là số hậu-xóa.
var exact = await ExactAsync(f.Db, project.Id, wi.Id);
var pend = await PendingAsync(f.Db, project.Id, wi.Id);
exact.PrevSubmittedTotal.Should().Be(100_000_000m, "phiếu đã xóa KHÔNG được quay lại PrevSubmitted");
exact.PrevSubmittedCount.Should().Be(1);
pend.PendingSelectedTotal.Should().Be(20_000_000m, "phiếu đã xóa KHÔNG được quay lại PendingSelected");
pend.PendingSelectedCount.Should().Be(1);
pend.PriorPes.Should().NotContain(p => p.Id == target.Id);
}
// =====================================================================
// T22b — list "Đã xóa" KHÔNG chứa phiếu SỐNG.
// 🔴 `IgnoreQueryFilters()` GỠ bộ lọc chứ không ĐẢO nó. Thiếu `.Where(x => x.IsDeleted)`
// (`PeSoftDeleteFeatures.cs:167-168`) thì màn này liệt kê TOÀN BỘ phiếu sống — mà build,
// mọi test khác và cả compiler đều xanh.
// Chứng-nhân: bảng có 3 row (2 sống + 1 xóa) nên nếu gỡ `.Where` thì kết quả là 3 ⇒ đỏ.
// =====================================================================
[Fact]
public async Task T22b_DeletedList_ExcludesLivePes_IgnoreQueryFiltersRemovesFilterNotInvertsIt()
{
using var f = new SoftDeleteFixture();
var db = f.Db;
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T22b");
var alice = await f.CreateUserAsync("alice.t22b@t.vn", "Trần Thị An");
var drafter = await f.CreateUserAsync("drafter.t22b@t.vn", "Người soạn", AppRoles.Drafter);
var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, true)]], "QT-DEL-T22b");
// 2 phiếu SỐNG — Project THẬT (handler INNER JOIN Projects, phiếu không có dự án sẽ tự rơi
// khỏi kết quả và làm phép đo mất răng).
var live1 = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T22b-LIVE1");
var live2 = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T22b-LIVE2",
phase: PurchaseEvaluationPhase.DangSoanThao);
var target = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T22b-DEL",
phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id);
f.ActAs(alice);
await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(target.Id), CancellationToken.None);
// Chứng-nhân: bảng CÓ 3 row; chỉ 1 mang cờ IsDeleted.
(await db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters().CountAsync())
.Should().Be(3, "tiền đề: 2 phiếu sống + 1 phiếu đã xóa cùng tồn tại trong bảng");
(await db.PurchaseEvaluations.AsNoTracking().CountAsync())
.Should().Be(2, "global filter cho 2 phiếu sống đi qua");
var admin = new TestCurrentUser(Guid.NewGuid(), "Quản trị", null, AppRoles.Admin);
var result = await NewDeletedListHandler(f, admin)
.Handle(new ListDeletedPurchaseEvaluationsQuery(), CancellationToken.None);
result.Total.Should().Be(1, "màn Đã xóa chỉ đếm phiếu đã xóa — bỏ `.Where(IsDeleted)` sẽ ra 3");
result.Items.Should().ContainSingle().Which.Id.Should().Be(target.Id);
result.Items.Select(i => i.Id).Should().NotContain(live1.Id).And.NotContain(live2.Id,
"phiếu đang sống KHÔNG được xuất hiện ở màn Đã xóa");
}
// =====================================================================
// T22c — IDOR: user ngoài phạm vi KHÔNG thấy phiếu đã xóa của người khác.
// Handler MỚI mặc định 0 rào ⇒ khối `PeSoftDeleteFeatures.cs:185-202` là thứ duy nhất chặn.
// 🔴 Chống assert-rỗng-nghĩa: CÙNG dữ liệu, CÙNG câu truy vấn, chỉ đổi NGƯỜI GỌI —
// người ngoài thấy 0, còn drafter và approver đều thấy 1. Nếu gỡ khối IDOR ⇒ dave thấy 1 ⇒ đỏ.
// =====================================================================
[Fact]
public async Task T22c_DeletedList_OutsiderSeesNothing_WhileDrafterAndApproverSeeIt()
{
using var f = new SoftDeleteFixture();
var db = f.Db;
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T22c");
var alice = await f.CreateUserAsync("alice.t22c@t.vn", "Trần Thị An");
var drafter = await f.CreateUserAsync("drafter.t22c@t.vn", "Người soạn", AppRoles.Drafter);
var dave = await f.CreateUserAsync("dave.t22c@t.vn", "Người ngoài"); // 0 role, 0 dính líu
var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, true)]], "QT-DEL-T22c");
var target = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T22c-DEL",
phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id,
budgetPeriod: 777_000_000m);
f.ActAs(alice);
await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(target.Id), CancellationToken.None);
var q = new ListDeletedPurchaseEvaluationsQuery();
// (a) NGƯỜI NGOÀI — không phải drafter, không role eligible, không approver quy trình đã pin.
var outsider = await NewDeletedListHandler(f, new TestCurrentUser(dave.Id, dave.FullName, dave.Email))
.Handle(q, CancellationToken.None);
outsider.Total.Should().Be(0,
"gỡ khối IDOR ⇒ mọi user đọc được phiếu đã xóa của dự án khác kèm ngân sách + tên gói thầu");
outsider.Items.Should().BeEmpty();
// (b) ĐỐI CHỨNG 1 — người soạn phiếu (cond `DrafterUserId == userId`).
var asDrafter = await NewDeletedListHandler(f,
new TestCurrentUser(drafter.Id, drafter.FullName, drafter.Email, AppRoles.Drafter))
.Handle(q, CancellationToken.None);
asDrafter.Items.Should().ContainSingle().Which.Id.Should().Be(target.Id,
"chứng-nhân: cùng dữ liệu + cùng truy vấn, người trong cuộc PHẢI thấy — nên số 0 ở trên là do rào, không do rỗng");
// (c) ĐỐI CHỨNG 2 — người duyệt của quy trình đã pin (cond V2-approver).
var asApprover = await NewDeletedListHandler(f,
new TestCurrentUser(alice.Id, alice.FullName, alice.Email))
.Handle(q, CancellationToken.None);
asApprover.Items.Should().ContainSingle().Which.Id.Should().Be(target.Id);
}
// =====================================================================
// T23 — Cấp KHÔNG bật cờ `AllowApproverDelete` ⇒ từ chối, KỂ CẢ khi đúng lượt.
// Biến duy nhất khác T25b là cái cờ (cặp cùng-ruột-khác-1-bit).
// =====================================================================
[Fact]
public async Task T23_ApproverAtCurrentTurn_ButFlagOff_IsForbidden_AndPeSurvives()
{
using var f = new SoftDeleteFixture();
var db = f.Db;
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T23");
var alice = await f.CreateUserAsync("alice.t23@t.vn", "Trần Thị An");
var drafter = await f.CreateUserAsync("drafter.t23@t.vn", "Người soạn", AppRoles.Drafter);
var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, false)]], "QT-DEL-T23"); // 🔴 cờ TẮT
var pe = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T23",
phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id);
f.ActAs(alice);
var act = async () => await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(pe.Id), CancellationToken.None);
var ex = (await act.Should().ThrowAsync<ForbiddenException>(
"đúng lượt vẫn chưa đủ — cờ F6 là rào riêng")).Which;
ex.Message.Should().Contain("Xóa phiếu khi đang duyệt",
"thông điệp phải chỉ đúng cái cờ cần bật, không phải lỗi 'chưa tới lượt'");
(await db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
.SingleAsync(p => p.Id == pe.Id)).IsDeleted
.Should().BeFalse("ném rồi thì phiếu phải còn nguyên — không xóa nửa vời");
}
// =====================================================================
// T24 — đúng cờ nhưng KHÔNG đúng lượt ⇒ từ chối.
// Cờ của carol để BẬT có chủ ý: để ca này đo đúng MỘT biến "lượt", không lẫn với ca cờ.
// =====================================================================
[Fact]
public async Task T24_ApproverOfLaterLevel_WithFlagOn_ButNotCurrentTurn_IsForbidden()
{
using var f = new SoftDeleteFixture();
var db = f.Db;
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T24");
var alice = await f.CreateUserAsync("alice.t24@t.vn", "Trần Thị An");
var carol = await f.CreateUserAsync("carol.t24@t.vn", "Phạm Thị Cúc");
var drafter = await f.CreateUserAsync("drafter.t24@t.vn", "Người soạn", AppRoles.Drafter);
// Cấp 1 = alice, Cấp 2 = carol — CẢ HAI đều bật cờ. Con trỏ đang ở Cấp 1.
var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, true), (2, carol.Id, true)]], "QT-DEL-T24");
var pe = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T24",
phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id, levelOrder: 1);
f.ActAs(carol);
var act = async () => await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(pe.Id), CancellationToken.None);
var ex = (await act.Should().ThrowAsync<ForbiddenException>(
"carol có cờ nhưng phiếu chưa tới Cấp 2 — cờ không cấp quyền vượt lượt")).Which;
ex.Message.Should().Contain("chưa tới lượt");
(await db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
.SingleAsync(p => p.Id == pe.Id)).IsDeleted.Should().BeFalse();
}
// =====================================================================
// T24b ⭐ PER-NGƯỜI, KHÔNG per-Cấp (owner chốt).
// Cấp 1 có 2 người CÙNG đúng lượt: alice bật cờ, bob KHÔNG. Cùng một phiếu, cùng một lời gọi
// — chỉ khác người thao tác: bob bị chặn, alice xóa được.
// 🔴 Đây là ca phân biệt `matchingLevel.AllowApproverDelete` (đúng) với `g.Any(l => ...)` (sai):
// với `Any`, bob sẽ xóa được ⇒ ca này đỏ ngay. Không có ca này thì hai cách đọc không phân biệt nổi.
// Thứ tự bob-trước-alice là bắt buộc: alice xóa xong thì phiếu biến mất khỏi global filter
// và lượt của bob sẽ nhận NotFound thay vì Forbidden — tức đo nhầm rào.
// =====================================================================
[Fact]
public async Task T24b_SameLevelTwoPeople_OnlyTheTickedOneCanDelete_PerRowNotPerLevel()
{
using var f = new SoftDeleteFixture();
var db = f.Db;
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T24b");
var alice = await f.CreateUserAsync("alice.t24b@t.vn", "Trần Thị An");
var bob = await f.CreateUserAsync("bob.t24b@t.vn", "Lê Văn Bình");
var drafter = await f.CreateUserAsync("drafter.t24b@t.vn", "Người soạn", AppRoles.Drafter);
// 1 Cấp — 2 dòng cùng Order 1 (OR-of-N). Chỉ alice được tick.
var wf = await SeedWorkflowAsync(db,
[[(1, alice.Id, true), (1, bob.Id, false)]], "QT-DEL-T24b");
var pe = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T24b",
phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id);
// ---- bob: đúng lượt, KHÔNG được tick ⇒ chặn ----
f.ActAs(bob);
var bobAct = async () => await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(pe.Id), CancellationToken.None);
var ex = (await bobAct.Should().ThrowAsync<ForbiddenException>(
"quyền huỷ chứng-từ phát theo TỪNG NGƯỜI; đọc per-Cấp bằng `Any` sẽ cho bob lọt")).Which;
ex.Message.Should().Contain("Xóa phiếu khi đang duyệt", "bob bị chặn vì CỜ, không phải vì lượt");
(await db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
.SingleAsync(p => p.Id == pe.Id)).IsDeleted
.Should().BeFalse("bob không được xóa gì cả");
// ---- alice: cùng Cấp, cùng lượt, CÓ tick ⇒ xóa được ----
f.Db.ChangeTracker.Clear();
f.ActAs(alice);
await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(pe.Id), CancellationToken.None);
(await db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
.SingleAsync(p => p.Id == pe.Id)).IsDeleted
.Should().BeTrue("cùng Cấp mà khác người ⇒ khác kết quả — đó chính là 'per-người'");
}
// =====================================================================
// T25 — quyền ở tầng API (Pattern 10 reflection, không dựng WebApplicationFactory).
// 3 mệnh đề, mất bất kỳ cái nào cũng là lỗ hổng thật:
// (i) `by-approver` PHẢI mang `[Authorize(Policy = "PurchaseEvaluations.Delete")]` —
// thiếu thì mọi tài khoản đăng nhập gọi được (class-level chỉ `[Authorize]` trần).
// (ii) tên policy phải CÓ THẬT trong bộ sinh của `Program.cs` (`MenuKeys.All × Actions`) —
// gắn tên không đăng ký thì ASP.NET ném lúc chạy mà test attribute vẫn xanh.
// (iii) đường xóa NHÁP cũ PHẢI vẫn KHÔNG có policy — gắn vào đó là cướp mất quyền xóa nháp
// của chính Drafter (11/13 role có CanDelete=0).
// =====================================================================
[Fact]
public void T25_DeleteByApprover_MustStayPolicyFree_AndDraftDeleteStaysUngated()
{
var t = typeof(PurchaseEvaluationsController);
// 🔴 ĐỔI SPEC 2026-07-27 (owner chốt sau đo của reviewer) — ca này TRƯỚC ĐÂY đòi
// `Policy == "PurchaseEvaluations.Delete"`. Nay đòi NGƯỢC LẠI: KHÔNG được có policy.
//
// Vì sao: đo trên DB thật, MỌI vai đang làm người duyệt (`Procurement`, `CostControl`, …)
// đều `CanDelete = 0`, và không seeder nào nâng cờ đó (`DbInitializer.cs:2139-2140` chỉ
// nâng Read/Create; `:2538` bỏ qua hàng đã tồn tại). Gắn policy ⇒ **403 với đúng người
// cần dùng**, mà FE gate cùng quyền nên không thấy nút, không thấy lỗi (gotcha #44).
//
// Nguồn quyền THẬT = 3 rào nghiệp vụ trong handler: phase ChoDuyet ∧ đúng lượt ∧
// `matchingLevel.AllowApproverDelete` (cờ per-NGƯỜI admin tick đích danh) — CHẶT HƠN
// permission theo vai. T23 / T24 / T24b / T25b là chứng-nhân cho 3 rào đó.
//
// ⚠️ Ca này là CHỐT CHẶN chống hồi quy: ai đó "siết bảo mật" bằng cách thêm lại
// `[Authorize(Policy=...)]` sẽ làm tính năng CHẾT IM LẶNG trên prod — và test này đỏ ngay.
GetActionAuthorize(t, nameof(PurchaseEvaluationsController.DeleteByApprover))
.Should().BeNull(
"đường xóa-khi-đang-duyệt CỐ Ý không gate bằng quyền theo vai — approver thật đều "
+ "CanDelete=0 nên gắn policy là chặn đúng người cần dùng; quyền nằm ở 3 rào handler");
GetActionAuthorize(t, nameof(PurchaseEvaluationsController.Delete))
.Should().BeNull(
"đường xóa NHÁP cũ phải giữ nguyên KHÔNG policy — gắn vào đó là chặn Drafter xóa nháp của chính mình");
GetActionAuthorize(t, nameof(PurchaseEvaluationsController.ListDeleted))!.Policy
.Should().Be("PurchaseEvaluations.Read", "màn Đã xóa là màn đọc, gate bằng quyền Read");
}
// =====================================================================
// T27 ⭐ HỢP-ĐỒNG ENTITY ⟷ DTO — sinh ra từ một bug THẬT của đợt này.
//
// Chuyện đã xảy ra: cờ `AllowApproverDelete` được thêm vào entity + migration + FE type,
// FE gate nút bằng `levelOptions?.allowApproverDelete === true` — nhưng **DTO không có
// field đó** nên BE không bao giờ gửi ⇒ vế so sánh LUÔN sai ⇒ **nút không hiện với bất
// kỳ ai**. BE build sạch, FE build sạch, 561 test xanh, `grep` tên ROUTE cũng khớp —
// không lưới nào bắt được, vì lỗ nằm ở tầng **tên FIELD**, không phải tên route.
//
// Ca này đóng CẢ LỚP đó: mọi cờ `Allow*` trên slot Cấp duyệt PHẢI có đường ra DTO.
// Thêm cờ mới vào entity mà quên DTO ⇒ đỏ NGAY, kèm tên cờ bị thiếu.
// =====================================================================
[Fact]
public void T27_EveryLevelAllowFlag_MustBeExposedOnOptionsDto()
{
var entityFlags = typeof(ApprovalWorkflowLevel)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.PropertyType == typeof(bool) && p.Name.StartsWith("Allow", StringComparison.Ordinal))
.Select(p => p.Name)
.OrderBy(n => n)
.ToList();
entityFlags.Should().NotBeEmpty("nếu rỗng thì phép đo này vô nghĩa — chứng-nhân cho chính nó");
var dtoMembers = typeof(ApprovalWorkflowOptionsDto)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name)
.ToHashSet(StringComparer.Ordinal);
var missing = entityFlags.Where(f => !dtoMembers.Contains(f)).ToList();
missing.Should().BeEmpty(
"mỗi cờ Allow* trên ApprovalWorkflowLevel phải có đường ra ApprovalWorkflowOptionsDto — "
+ "thiếu field nào thì FE đọc `undefined`, gate fail-closed, nút/quyền tương ứng "
+ "KHÔNG BAO GIỜ hiện, mà cả BE lẫn FE đều build sạch. Cờ đang thiếu: {0}",
string.Join(", ", missing));
}
private static AuthorizeAttribute? GetActionAuthorize(Type controllerType, string methodName)
=> controllerType
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.FirstOrDefault(m => m.Name == methodName)
?.GetCustomAttributes<AuthorizeAttribute>(inherit: false)
.FirstOrDefault();
// =====================================================================
// T25b ⭐ CHIỀU THUẬN — đủ quyền ∧ đúng lượt ∧ đúng cờ ⇒ THÀNH CÔNG.
// Không có ca này thì T23/T24/T24b vẫn xanh trọn vẹn kể cả khi tính năng CHẾT HẲN
// (một handler `throw` vô điều kiện làm mọi ca phủ định pass).
// Đo trọn hợp đồng: không ném · row Ở LẠI với IsDeleted/DeletedAt/DeletedBy · biến mất khỏi
// truy vấn thường · hiện ra ở màn "Đã xóa".
// =====================================================================
[Fact]
public async Task T25b_RightPersonRightTurnRightFlag_SoftDeletesSuccessfully_AndShowsInDeletedScreen()
{
using var f = new SoftDeleteFixture();
var db = f.Db;
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T25b");
var alice = await f.CreateUserAsync("alice.t25b@t.vn", "Trần Thị An");
var drafter = await f.CreateUserAsync("drafter.t25b@t.vn", "Người soạn", AppRoles.Drafter);
var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, true)]], "QT-DEL-T25b");
var pe = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T25b",
phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id);
f.ActAs(alice);
var act = async () => await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(pe.Id), CancellationToken.None);
await act.Should().NotThrowAsync("đủ cả 3 điều kiện thì đường này phải đi được — nếu không, tính năng chết");
var row = await db.PurchaseEvaluations.AsNoTracking().IgnoreQueryFilters()
.SingleAsync(p => p.Id == pe.Id);
row.IsDeleted.Should().BeTrue();
row.DeletedAt.Should().Be(f.Clock.UtcNow, "AuditingInterceptor đóng dấu thời điểm xóa");
row.DeletedBy.Should().Be(alice.Id, "phải ghi ĐÚNG người bấm xóa");
row.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "xóa mềm KHÔNG đổi trạng thái phiếu");
(await db.PurchaseEvaluations.AsNoTracking().AnyAsync(p => p.Id == pe.Id))
.Should().BeFalse("global filter phải giấu phiếu khỏi mọi truy vấn thường");
var admin = new TestCurrentUser(Guid.NewGuid(), "Quản trị", null, AppRoles.Admin);
var deleted = await NewDeletedListHandler(f, admin)
.Handle(new ListDeletedPurchaseEvaluationsQuery(), CancellationToken.None);
deleted.Items.Should().ContainSingle().Which.Id.Should().Be(pe.Id,
"xóa mềm = phiếu đi sang màn Đã xóa, không bốc hơi");
}
// =====================================================================
// T26 — GHI VẾT. `PurchaseEvaluationChangelog` là chỗ DUY NHẤT chứa LÝ DO
// (`DeletedBy`/`DeletedAt` chỉ trả lời ai + lúc nào). Xóa một chứng-từ tài chính mà mất vết
// lý do thì màn "Đã xóa" chỉ còn là danh sách xác không giải thích được.
// Đối chứng trong cùng ca: xóa KHÔNG nhập lý do ⇒ `ContextNote` phải là null (nhánh trim/null),
// để "có ContextNote" ở phiếu trên không thể là hằng số.
//
// 🔴🔴 CA NÀY ĐANG ĐỎ — BUG PRODUCTION, CỐ Ý GIỮ ĐỎ (test-specialist KHÔNG sửa src/Backend).
// Vết KHÔNG BAO GIỜ được ghi. Cơ chế đã đo bằng chẩn đoán riêng (ChangeTracker), không suy đoán:
// `PeSoftDeleteFeatures.cs:126` Add changelog → state = Added, EF fixup gắn nó vào
// `pe.Changelogs` (navigation 2 chiều, `PurchaseEvaluationConfiguration.cs:78`).
// `PeSoftDeleteFeatures.cs:142` Remove(pe) → `CascadeDeleteTiming.Immediate` (mặc định)
// cascade ngay TẠI DÒNG NÀY; dependent đang ở `Added` khi bị cascade thì EF chuyển
// thành **Detached** (xóa một entity Added = gỡ theo dõi). ⇒ đo được: Added → Detached.
// `:143` SaveChanges → 0 row changelog. `AuditingInterceptor` chạy ở SavingChanges, tức SAU
// khi cascade đã xảy ra ⇒ 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 và prod là interceptor, mà interceptor
// nằm SAU điểm mất mát. Xóa mềm vẫn chạy đúng (T25b xanh) — chỉ LÝ DO bị nuốt IM LẶNG.
// Đã grep cùng lớp: đây là site DUY NHẤT trong `src/Backend` có hình "Add con → Remove cha →
// 1 SaveChanges" (đường xóa nháp cũ `PurchaseEvaluationFeatures.cs:1408` không ghi changelog).
// =====================================================================
[Fact]
public async Task T26_Delete_WritesChangelogWithDeleteAction_AndCarriesReasonWhenProvided()
{
using var f = new SoftDeleteFixture();
var db = f.Db;
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T26");
var alice = await f.CreateUserAsync("alice.t26@t.vn", "Trần Thị An");
var drafter = await f.CreateUserAsync("drafter.t26@t.vn", "Người soạn", AppRoles.Drafter);
var wf = await SeedWorkflowAsync(db, [[(1, alice.Id, true)]], "QT-DEL-T26");
var levelId = wf.Steps.Single().Levels.Single().Id;
var withReason = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T26-REASON",
phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id);
var noReason = await SeedPeAsync(db, project.Id, wi.Id, drafter.Id, "PE-T26-NOREASON",
phase: PurchaseEvaluationPhase.ChoDuyet, approvalWorkflowId: wf.Id);
f.ActAs(alice);
await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(withReason.Id, " Bấm sai gói thầu "),
CancellationToken.None);
f.Db.ChangeTracker.Clear();
await NewDeleteHandler(f).Handle(
new DeletePurchaseEvaluationByApproverCommand(noReason.Id, " "),
CancellationToken.None);
var logs = await db.PurchaseEvaluationChangelogs.AsNoTracking()
.Where(c => c.Action == ChangelogAction.Delete)
.ToListAsync(CancellationToken.None);
logs.Should().HaveCount(2, "mỗi lần xóa phải để lại đúng 1 vết");
var a = logs.Single(l => l.PurchaseEvaluationId == withReason.Id);
a.EntityType.Should().Be(PurchaseEvaluationEntityType.Workflow);
a.EntityId.Should().Be(levelId, "vết trỏ đúng dòng Cấp mà người xóa đang đứng");
a.PhaseAtChange.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "trạng thái TẠI LÚC xóa");
a.UserId.Should().Be(alice.Id);
a.UserName.Should().Be("Trần Thị An");
a.Summary.Should().Contain("Xóa phiếu khi đang duyệt");
a.ContextNote.Should().Be("Bấm sai gói thầu", "lý do phải được lưu và cắt khoảng trắng thừa");
var b = logs.Single(l => l.PurchaseEvaluationId == noReason.Id);
b.ContextNote.Should().BeNull("chuỗi toàn khoảng trắng = không nhập lý do ⇒ lưu null, không lưu rỗng");
// Vết phải ĐỌC ĐƯỢC sau khi phiếu biến mất khỏi global filter (changelog không có IsDeleted
// và truy vấn changelog lọc theo PurchaseEvaluationId chứ không join phiếu).
(await db.PurchaseEvaluations.AsNoTracking().AnyAsync(p => p.Id == withReason.Id))
.Should().BeFalse("tiền đề: phiếu đã bị giấu — vết vẫn phải còn đọc được ở trên");
}
}