[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
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:
@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user