[CLAUDE] Contract: YC-029 W5 — đường xoá đồng-khuôn KHKK (owner-check + phase allow-list + changelog, test-before 13 ca)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-12 20:19:32 +07:00
parent 8a64f536e5
commit 2ae62f3fbc
7 changed files with 781 additions and 11 deletions

View File

@ -0,0 +1,500 @@
using FluentValidation; // IValidator<T> — kiểu tham số của ValidationBehavior
using MediatR;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Behaviors; // ValidationBehavior — đường mà 2001 ký tự đi qua trong prod
using SolutionErp.Application.Common.Exceptions;
// Cùng alias với chính `ValidationBehavior.cs`: `ValidationException` tồn tại ở CẢ FluentValidation
// lẫn Application.Common.Exceptions. T-12 phải bắt ĐÚNG cái của app (cái map ra HTTP 400).
using ValidationException = SolutionErp.Application.Common.Exceptions.ValidationException;
using SolutionErp.Application.Common.Interfaces; // ICurrentUser — bơm cho AuditingInterceptor
using SolutionErp.Application.Contracts;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity; // AppRoles
using SolutionErp.Infrastructure.Persistence;
using SolutionErp.Infrastructure.Persistence.Interceptors;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// [W5a YC-029 — S190 2026-08-12] Đường XOÁ Hợp đồng đồng-khuôn KHKK — **TEST-BEFORE**.
//
// Nguồn ca DUY NHẤT: `runs/2026-08-12-S190-soltuon-dieu-chinh-hd/style-gate-v2.md` §C T-MATRIX
// (13 ca T-1..T-13). Chi tiết bẫy per-ca: `sub-b5-plan-final.md` §W5 việc 1.
//
// ── LOẠI ĐỎ MONG ĐỢI (đọc trước khi hoảng) ───────────────────────────────────────
// File này viết theo HÀNH VI ĐÍCH sau W5, trong khi mã production hiện tại
// (`ContractFeatures.cs:885-902`) mới chỉ có:
// record DeleteContractCommand(Guid Id) // CHƯA có Reason
// DeleteContractCommandHandler(IApplicationDbContext db) // CHƯA nhận ICurrentUser
// (KHÔNG có DeleteContractCommandValidator)
// ⇒ đỏ ở đây là **ĐỎ COMPILE**, không phải đỏ assert. Đúng bản chất test-before: hợp đồng API
// của handler được CHỐT bằng test trước khi ai gõ dòng production nào. BE worker làm xanh bằng
// cách nở đúng 3 thứ trên — **KHÔNG được sửa test cho vừa mã** (Đ-3).
//
// 🔴 3 TÊN BỊ PIN bởi file này (BE phải dùng ĐÚNG, sai tên = vẫn đỏ compile):
// (1) `DeleteContractCommand(Guid Id, string? Reason = null)`
// (2) `DeleteContractCommandHandler(IApplicationDbContext db, ICurrentUser currentUser)`
// (3) `DeleteContractCommandValidator : AbstractValidator<DeleteContractCommand>`
// — khuôn `DeleteContractSigningPlanCommandValidator` (`ContractSigningPlanFeatures.cs:1743`).
//
// ── 2 quyết định hạ tầng, cả hai là ĐIỀU KIỆN để phép đo có nghĩa ────────────────
//
// (1) Fixture RIÊNG có `AuditingInterceptor` (KHÔNG dùng `SqliteDbFixture`). `TestApplicationDbContext`
// mặc định KHÔNG wire interceptor ⇒ `Remove()` là xoá **CỨNG** (bài S155). Đo "xoá mềm +
// DeletedBy + row còn trong DB" trên xoá-cứng là đo NHẦM cơ chế: mọi assert dạng
// `IgnoreQueryFilters().Single(...)` sẽ nổ vì row bốc hơi, còn assert `Count()==0` thì XANH
// kể cả khi soft-delete chưa từng tồn tại. Nếp fixture private-per-file theo họ Contract
// (`ContractSigningPlanCrudTests.cs:50` `KhkkFixture` · `ContractCatalogTests.cs:83`).
//
// (2) MỖI lệnh chạy trên MỘT DbContext MỚI (`NewDb()`), dùng chung 1 connection — mô phỏng
// scoped-per-request của prod. Tái dùng 1 context cho cả seed lẫn lệnh thì các con
// (Changelogs) đang được TRACK sẽ bị EF cascade client-side ngay tại `Remove()` — đường đi
// KHÔNG xảy ra trong prod (ở đó context mới chỉ nạp mỗi header). Chính sai lệch này đẻ ra
// bug T26 (S155) mà T-1 dưới đây canh.
//
// ── Ghi chú SPEC vs CODE ─────────────────────────────────────────────────────────
// Allow-list phase của HĐ **RỘNG HƠN** KHKK: `Phase < DangInKy(5)` {TraLai(98), TuChoi(99)}
// — tức gồm cả legacy 1/3/4, KHÔNG phải bộ 3 {DangSoanThao, TuChoi, TraLai} của KHKK. T-4 là ca
// canh đúng chỗ này (chép nguyên hình pattern KHKK = 409 oan cho HĐ legacy).
// T-14 (phản-ca) CỐ Ý KHÔNG có mặt: không viết test reflection-authz cho DELETE — chiều khẳng
// định sai với shape hiện tại (`[Authorize]` trần), chiều phủ định PIN LỖ và sẽ đỏ oan khi W8 mở.
public class ContractDeleteGuardTests
{
private static readonly DateTime Clock2026 = new(2026, 8, 12, 3, 0, 0, DateTimeKind.Utc);
// ===================================================================
// Fixture: SQLite in-memory + AuditingInterceptor (xoá mềm THẬT).
// ===================================================================
private sealed class ContractDeleteFixture : IDisposable
{
private readonly SqliteConnection _conn;
public FixedDateTime Clock { get; } = new(Clock2026);
/// Người soạn HĐ mặc định (chủ phiếu) — đổi actor giữa chừng để đóng vai người khác.
public TestCurrentUser Owner { get; } = new(Guid.NewGuid(), "Nguyễn Văn Soạn", "soan@test.local");
public ContractDeleteFixture()
{
_conn = new SqliteConnection("DataSource=:memory:");
_conn.Open();
using var boot = NewDb();
boot.Database.EnsureCreated();
}
/// Context MỚI cho mỗi "request" (xem ghi chú hạ tầng (2)).
/// 🔴 `actor` phải là ĐÚNG cái `ICurrentUser` mà handler nhận: trong prod cả interceptor
/// lẫn handler đều resolve CÙNG một `ICurrentUser` scoped. Để interceptor dính cứng một
/// người còn handler chạy bằng người khác thì `DeletedBy` đo ra người sai — và sai theo
/// hướng TRÔNG NHƯ ĐÚNG (vẫn là một Guid user thật).
public TestApplicationDbContext NewDb(ICurrentUser? actor = null)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(_conn)
.EnableSensitiveDataLogging()
.AddInterceptors(new AuditingInterceptor(actor ?? Owner, Clock))
.Options;
return new TestApplicationDbContext(options);
}
public void Dispose() => _conn.Dispose();
}
// ===================================================================
// Seed: 1 HĐ + 1 dòng nhật ký CÓ SẴN.
//
// `Contract.SupplierId/ProjectId` là Guid LOOSE (không FK vật lý —
// `ContractConfiguration.cs:29-31` chỉ có index) ⇒ không cần seed master.
//
// Dòng nhật ký `Insert` có sẵn KHÔNG phải trang trí: nó biến assert changelog thành phép đo
// PHÂN BIỆT — "đúng 1 dòng Delete" trên nền đã có 1 dòng khác chứng minh test đọc theo
// Action chứ không đếm bừa, đồng thời canh vết cũ không bị lượt xoá cuốn theo.
// ===================================================================
private static async Task<Guid> SeedContractAsync(
ContractDeleteFixture f,
ContractPhase phase,
Guid? drafterUserId,
string? maHopDong = null)
{
await using var db = f.NewDb();
var id = Guid.NewGuid();
db.Contracts.Add(new Contract
{
Id = id,
Type = ContractType.HopDongThauPhu,
Phase = phase,
SupplierId = Guid.NewGuid(),
ProjectId = Guid.NewGuid(),
DrafterUserId = drafterUserId,
GiaTri = 1_500_000_000m,
TenHopDong = "HĐ thầu phụ phần thô",
MaHopDong = maHopDong,
});
db.ContractChangelogs.Add(new ContractChangelog
{
ContractId = id,
EntityType = ChangelogEntityType.Contract,
Action = ChangelogAction.Insert,
PhaseAtChange = ContractPhase.DangSoanThao,
UserId = drafterUserId,
UserName = "Nguyễn Văn Soạn",
Summary = "Tạo hợp đồng",
});
await db.SaveChangesAsync(CancellationToken.None);
return id;
}
/// Chạy lệnh xoá qua handler THẬT, 1 context mới, interceptor + handler CÙNG actor (như prod).
private static async Task DeleteAsync(
ContractDeleteFixture f, Guid contractId, string? reason, TestCurrentUser actor)
{
await using var db = f.NewDb(actor);
var handler = new DeleteContractCommandHandler(db, actor);
await handler.Handle(new DeleteContractCommand(contractId, reason), CancellationToken.None);
}
private static TestCurrentUser Stranger()
=> new(Guid.NewGuid(), "Người lạ", "la@test.local");
private static TestCurrentUser Admin()
=> new(Guid.NewGuid(), "Quản trị", "admin@test.local", AppRoles.Admin);
// ===================================================================
// T-1 — ca gốc: 3 phép đo trong 1 lượt, mỗi phép canh một tầng khác nhau.
// (a) khuất khỏi query thường → global filter còn hiệu lực
// (b) IgnoreQueryFilters → row VẪN NẰM trong DB, IsDeleted/DeletedBy đúng người
// (c) changelog đúng 1 dòng → thứ tự Remove-trước/Add-sau (bug T26 S155)
// Thiếu (b) thì xoá cứng cũng xanh; thiếu (c) thì lý do xoá bốc hơi im lặng, không lỗi.
// ===================================================================
[Fact]
// T-1: HĐ nháp của chính mình → xoá MỀM + để lại đúng 1 vết nhật ký có lý do.
public async Task DeleteContract_DraftByOwner_SoftDeletes_AndWritesOneChangelogRow()
{
using var f = new ContractDeleteFixture();
var id = await SeedContractAsync(f, ContractPhase.DangSoanThao, f.Owner.UserId);
await DeleteAsync(f, id, "Lập nhầm nhà cung cấp", f.Owner);
await using var db = f.NewDb();
// (a) khuất khỏi mọi màn hình thường.
db.Contracts.Count(c => c.Id == id)
.Should().Be(0, "HĐ đã xoá không được lọt vào danh sách/đếm của màn hình thường");
// (b) nhưng row vẫn còn để truy — xoá MỀM, không phải bốc hơi.
var raw = db.Contracts.IgnoreQueryFilters().AsNoTracking().Single(c => c.Id == id);
raw.IsDeleted.Should().BeTrue("xoá mềm — vết phải còn để truy");
raw.DeletedBy.Should().Be(f.Owner.UserId, "người xoá thật phải được ghi lại");
// (c) nhật ký: dòng cũ còn sống + đúng 1 dòng Delete mang đủ ngữ cảnh.
var logs = db.ContractChangelogs.AsNoTracking().Where(l => l.ContractId == id).ToList();
logs.Should().HaveCount(2, "1 dòng lúc tạo + 1 dòng lúc xoá");
var del = logs.Single(l => l.Action == ChangelogAction.Delete);
del.EntityType.Should().Be(ChangelogEntityType.Contract, "xoá HEADER, không phải con");
del.EntityId.Should().BeNull("header thì EntityId để trống (khuôn 2 site ghi tay)");
del.PhaseAtChange.Should().Be(ContractPhase.DangSoanThao, "snapshot phase lúc xoá");
del.UserId.Should().Be(f.Owner.UserId);
del.UserName.Should().Be(f.Owner.FullName);
del.ContextNote.Should().Be("Lập nhầm nhà cung cấp",
"LÝ DO chỉ sống ở changelog — DeletedBy/DeletedAt chỉ trả lời ai + lúc nào");
}
// ===================================================================
// T-2 — `TraLai` (98). Dev 0 row ⇒ seed THẲNG Phase (style-gate §D dữ-liệu đo tay).
// Ý nghĩa: phiếu bị trả về tay người soạn thì KHÔNG chiếm lượt duyệt của ai
// ⇒ cùng vị thế với nháp. `PhaseAtChange` assert theo phase THẬT (98) để chứng
// snapshot đọc từ entity chứ không hardcode DangSoanThao.
// ===================================================================
[Fact]
// T-2: HĐ ở Trả lại (98) → chủ HĐ xoá được.
public async Task DeleteContract_TraLai_ByOwner_Succeeds()
{
using var f = new ContractDeleteFixture();
var id = await SeedContractAsync(f, ContractPhase.TraLai, f.Owner.UserId);
await DeleteAsync(f, id, "Bị trả lại, soạn lại từ đầu", f.Owner);
await using var db = f.NewDb();
db.Contracts.Count(c => c.Id == id).Should().Be(0);
db.Contracts.IgnoreQueryFilters().AsNoTracking().Single(c => c.Id == id)
.IsDeleted.Should().BeTrue();
db.ContractChangelogs.AsNoTracking()
.Single(l => l.ContractId == id && l.Action == ChangelogAction.Delete)
.PhaseAtChange.Should().Be(ContractPhase.TraLai,
"snapshot phải là phase THẬT lúc xoá, không phải hằng số");
}
// ===================================================================
// T-3 — `TuChoi` (99). Terminal khoá, nhưng khoá là khoá SỬA, không phải khoá DỌN.
// ===================================================================
[Fact]
// T-3: HĐ ở Từ chối (99) → chủ HĐ xoá được.
public async Task DeleteContract_TuChoi_ByOwner_Succeeds()
{
using var f = new ContractDeleteFixture();
var id = await SeedContractAsync(f, ContractPhase.TuChoi, f.Owner.UserId);
await DeleteAsync(f, id, "Dọn phiếu bị từ chối", f.Owner);
await using var db = f.NewDb();
db.Contracts.Count(c => c.Id == id).Should().Be(0);
db.ContractChangelogs.AsNoTracking()
.Single(l => l.ContractId == id && l.Action == ChangelogAction.Delete)
.PhaseAtChange.Should().Be(ContractPhase.TuChoi);
}
// ===================================================================
// T-4 — CHỐNG VÁ-QUÁ-TAY (style-gate be2-R7). Allow-list HĐ là `Phase < DangInKy(5)`, tức
// gồm CẢ legacy 1/3/4 — rộng hơn bộ 3 của KHKK. Ai chép nguyên hình
// `is not (DangSoanThao or TuChoi or TraLai)` sẽ 409 oan đúng 3 phase này (Dev đang có
// HĐ sống ở `DangGopY`). Đo cả 1/3/4 trong 1 Fact vì chúng CÙNG một lớp ca — thêm phase
// là thêm điểm đo, không phải thêm ca.
// ===================================================================
[Fact]
// T-4: HĐ legacy phase 1/3/4 (< DangInKy) → chủ HĐ vẫn xoá được.
public async Task DeleteContract_LegacyPhaseBelowDangInKy_ByOwner_Succeeds()
{
using var f = new ContractDeleteFixture();
foreach (var phase in new[] { ContractPhase.DangChon, ContractPhase.DangGopY, ContractPhase.DangDamPhan })
{
var id = await SeedContractAsync(f, phase, f.Owner.UserId);
await DeleteAsync(f, id, $"Dọn HĐ legacy {phase}", f.Owner);
await using var db = f.NewDb();
db.Contracts.Count(c => c.Id == id)
.Should().Be(0, $"phase {phase} nằm dưới DangInKy nên vẫn thuộc quyền dọn của người soạn");
db.ContractChangelogs.AsNoTracking()
.Single(l => l.ContractId == id && l.Action == ChangelogAction.Delete)
.PhaseAtChange.Should().Be(phase);
}
}
// ===================================================================
// T-5 — `ChoDuyet` (10): CHỦ HĐ cũng không được rút êm. Người đang chờ duyệt mất lượt.
// (Đường xoá-khi-đang-duyệt dành cho approver là Q4, KHÔNG thuộc đợt này.)
// Vế "còn nguyên" bắt buộc: ném xong mà vẫn ghi thì tệ hơn không rào.
// ===================================================================
[Fact]
// T-5: HĐ đang chờ duyệt + chính chủ → 409 Conflict, HĐ còn nguyên.
public async Task DeleteContract_ChoDuyet_ByOwner_ThrowsConflict()
{
using var f = new ContractDeleteFixture();
var id = await SeedContractAsync(f, ContractPhase.ChoDuyet, f.Owner.UserId);
var act = async () => await DeleteAsync(f, id, null, f.Owner);
await act.Should().ThrowAsync<ConflictException>(
"HĐ đang chờ duyệt thì chủ HĐ cũng không được rút êm");
await using var db = f.NewDb();
db.Contracts.Count(c => c.Id == id)
.Should().Be(1, "chặn xong HĐ phải còn NGUYÊN trong danh sách thường");
db.ContractChangelogs.AsNoTracking()
.Count(l => l.ContractId == id && l.Action == ChangelogAction.Delete)
.Should().Be(0, "chặn rồi thì KHÔNG được để lại vết xoá ma");
}
// ===================================================================
// T-6 — `DaPhatHanh` (9) + **Admin**: đã gen mã HĐ thì không ai xoá, kể cả Admin.
// Actor Admin là cố ý: nó chứng rào phase đứng ĐỘC LẬP với rào chủ-sở-hữu, không phải
// hệ quả của việc Admin bị chặn ở tầng khác.
// +Biên `DangInKy(5)`: allow-list là `< 5` STRICT ⇒ chính phase 5 phải bị chặn. Không có
// điểm đo tại biên thì `<=` và `<` không phân biệt được (bài S69b).
// ===================================================================
[Fact]
// T-6: HĐ đã phát hành (9) + Admin → 409 Conflict; biên DangInKy(5) cũng bị chặn.
public async Task DeleteContract_DaPhatHanh_ByAdmin_ThrowsConflict()
{
using var f = new ContractDeleteFixture();
var admin = Admin();
var released = await SeedContractAsync(
f, ContractPhase.DaPhatHanh, f.Owner.UserId, maHopDong: "FLOCK01/HĐTP/SOL&PVL/01");
var actReleased = async () => await DeleteAsync(f, released, "dọn dẹp", admin);
await actReleased.Should().ThrowAsync<ConflictException>(
"HĐ đã phát hành đã mang mã chính thức — Admin cũng không xoá");
var atBoundary = await SeedContractAsync(f, ContractPhase.DangInKy, f.Owner.UserId);
var actBoundary = async () => await DeleteAsync(f, atBoundary, "dọn dẹp", admin);
await actBoundary.Should().ThrowAsync<ConflictException>(
"biên: allow-list là Phase < DangInKy, nên chính DangInKy phải bị chặn");
await using var db = f.NewDb();
db.Contracts.Count(c => c.Id == released || c.Id == atBoundary)
.Should().Be(2, "cả hai HĐ phải còn nguyên sau khi bị chặn");
}
// ===================================================================
// T-7 — người lạ (không phải người soạn, không Admin) đụng HĐ NHÁP của người khác.
// Đây là lỗ SẴN CÓ đang vá (gotcha #82): trước W5 mọi user đăng nhập đều xoá được
// HĐ của người khác vì handler không hề hỏi actor là ai.
// ===================================================================
[Fact]
// T-7: HĐ nháp của người khác + người lạ → 403 Forbidden.
public async Task DeleteContract_DraftOfAnotherUser_ByStranger_ThrowsForbidden()
{
using var f = new ContractDeleteFixture();
var id = await SeedContractAsync(f, ContractPhase.DangSoanThao, f.Owner.UserId);
var act = async () => await DeleteAsync(f, id, null, Stranger());
await act.Should().ThrowAsync<ForbiddenException>(
"chỉ người soạn hoặc Admin được xoá HĐ này");
await using var db = f.NewDb();
db.Contracts.Count(c => c.Id == id).Should().Be(1, "chặn xong HĐ phải còn NGUYÊN");
}
// ===================================================================
// T-8 — VẾ DƯƠNG của `|| isAdmin`. Không có ca này thì T-7/T-5/T-6/T-9/T-10/T-11 vẫn xanh
// trong cái thế giới mà handler ném với MỌI đầu vào. `DeletedBy` phải là ADMIN, không
// phải chủ HĐ — canh luôn chuyện interceptor và handler chạy cùng một actor.
// ===================================================================
[Fact]
// T-8: HĐ nháp của người khác + Admin → xoá được, DeletedBy ghi đúng Admin.
public async Task DeleteContract_DraftOfAnotherUser_ByAdmin_Succeeds()
{
using var f = new ContractDeleteFixture();
var admin = Admin();
var id = await SeedContractAsync(f, ContractPhase.DangSoanThao, f.Owner.UserId);
await DeleteAsync(f, id, "Admin dọn HĐ nháp bỏ quên", admin);
await using var db = f.NewDb();
db.Contracts.Count(c => c.Id == id).Should().Be(0);
db.Contracts.IgnoreQueryFilters().AsNoTracking().Single(c => c.Id == id)
.DeletedBy.Should().Be(admin.UserId,
"người xoá thật là Admin, không phải chủ HĐ");
db.ContractChangelogs.AsNoTracking()
.Single(l => l.ContractId == id && l.Action == ChangelogAction.Delete)
.UserId.Should().Be(admin.UserId);
}
// ===================================================================
// T-9 — NẤC 0 (khuôn `PeSoftDeleteFeatures.cs:56-58`). Khuôn KHKK `:1763` KHÔNG có nấc này:
// nó so thẳng `DrafterUserId == currentUser.UserId`, nên HĐ `DrafterUserId = null` gặp
// actor chưa đăng nhập ⇒ `null == null` ⇒ isOwner = TRUE ⇒ xoá LỌT.
// Ca này là lý do "đừng chép nguyên hình KHKK": nếu BE chép, test không ném gì cả và HĐ
// biến mất — chính xác cái ta sợ. Kỳ vọng `UnauthorizedException` (chưa đăng nhập),
// KHÔNG phải Forbidden (đã đăng nhập nhưng không đủ quyền) — hai mã HTTP khác nhau.
// ===================================================================
[Fact]
// T-9: HĐ DrafterUserId=null + actor UserId=null → 401 Unauthorized, HĐ còn nguyên.
public async Task DeleteContract_NullDrafter_ByAnonymousActor_ThrowsUnauthorized()
{
using var f = new ContractDeleteFixture();
var id = await SeedContractAsync(f, ContractPhase.DangSoanThao, drafterUserId: null);
var anonymous = TestCurrentUser.System(); // UserId = null ⇒ IsAuthenticated = false
var act = async () => await DeleteAsync(f, id, null, anonymous);
await act.Should().ThrowAsync<UnauthorizedException>(
"chưa đăng nhập thì dừng ở nấc 0 — không được để null == null thành 'chủ sở hữu'");
await using var db = f.NewDb();
db.Contracts.Count(c => c.Id == id)
.Should().Be(1, "HĐ mồ côi người soạn KHÔNG được thành HĐ ai cũng xoá");
}
// ===================================================================
// T-10 — THỨ TỰ ném: NotFound TRƯỚC Forbidden. Actor cố ý là người lạ để hai khả năng đều
// mở; handler kiểm quyền trước khi nạp sẽ trả 403 và làm lộ "id này có tồn tại hay không"
// theo chiều ngược (403 = có, 404 = không). Chỉ đúng thứ tự mới cho 404 ở đây.
// ===================================================================
[Fact]
// T-10: id không tồn tại + người lạ → 404 NotFound (không phải 403).
public async Task DeleteContract_UnknownId_ByStranger_ThrowsNotFoundBeforeForbidden()
{
using var f = new ContractDeleteFixture();
await SeedContractAsync(f, ContractPhase.DangSoanThao, f.Owner.UserId); // thế giới KHÔNG rỗng
var act = async () => await DeleteAsync(f, Guid.NewGuid(), null, Stranger());
await act.Should().ThrowAsync<NotFoundException>(
"không tìm thấy thì báo không tìm thấy — kiểm quyền trước khi nạp là sai thứ tự");
}
// ===================================================================
// T-11 — THỨ TỰ ném: Forbidden TRƯỚC Conflict. Ca này có ĐỦ điều kiện cho cả hai (người lạ
// + phase cấm) nên nó phân biệt được thứ tự rào; T-5 (chính chủ + ChoDuyet) và T-7
// (người lạ + nháp) mỗi cái chỉ chạm một rào nên KHÔNG phân biệt được.
// Lý do nghiệp vụ: người ngoài không đáng được biết HĐ đó đang ở bước nào.
// ===================================================================
[Fact]
// T-11: HĐ chờ duyệt của người khác + người lạ → 403 Forbidden (không phải 409).
public async Task DeleteContract_ChoDuyetOfAnotherUser_ByStranger_ThrowsForbiddenBeforeConflict()
{
using var f = new ContractDeleteFixture();
var id = await SeedContractAsync(f, ContractPhase.ChoDuyet, f.Owner.UserId);
var act = async () => await DeleteAsync(f, id, null, Stranger());
await act.Should().ThrowAsync<ForbiddenException>(
"rào chủ-sở-hữu đứng trước rào phase — người ngoài không được biết HĐ đang ở bước nào");
await using var db = f.NewDb();
db.Contracts.Count(c => c.Id == id).Should().Be(1);
}
// ===================================================================
// T-12 — Reason 2001 ký tự. Đo qua `ValidationBehavior` THẬT (đường mà request đi trong
// prod) chứ không chỉ `validator.Validate(...)`: nó chứng luôn rằng lỗi nổi lên đúng
// `Application.Common.Exceptions.ValidationException` (→ 400) chứ không phải
// `DbUpdateException` khi EF cắt ở `HasMaxLength(2000)`
// (`ContractChangelogConfiguration.cs:20` — validator PHẢI match EF, không tự chế số).
// Control dương ĐÚNG 2000 ký tự: thiếu nó thì một validator ném với mọi Reason cũng xanh.
// ===================================================================
[Fact]
// T-12: Reason 2001 ký tự → ValidationException; đúng 2000 ký tự thì lọt (biên).
public async Task DeleteContract_ReasonOver2000Chars_FailsValidation()
{
var behavior = new ValidationBehavior<DeleteContractCommand, Unit>(
new IValidator<DeleteContractCommand>[] { new DeleteContractCommandValidator() });
var reached = false;
RequestHandlerDelegate<Unit> next = () => { reached = true; return Task.FromResult(Unit.Value); };
var tooLong = new DeleteContractCommand(Guid.NewGuid(), new string('x', 2001));
var act = async () => await behavior.Handle(tooLong, next, CancellationToken.None);
var ex = await act.Should().ThrowAsync<ValidationException>(
"2001 ký tự vượt trần 2000 của cột ContextNote — phải chặn ở tầng validate, không để EF nổ");
ex.Which.Errors.Should().ContainKey(nameof(DeleteContractCommand.Reason));
reached.Should().BeFalse("chặn ở validate thì handler không được chạy");
// Control dương: đúng trần thì đi tiếp.
var atLimit = new DeleteContractCommand(Guid.NewGuid(), new string('x', 2000));
await behavior.Handle(atLimit, next, CancellationToken.None);
reached.Should().BeTrue("đúng 2000 ký tự là hợp lệ — rào là ĐÚNG BIÊN, không phải cấm tuốt");
}
// ===================================================================
// T-13 — Reason toàn khoảng trắng ⇒ vẫn xoá được, nhưng ContextNote lưu NULL (trim-to-null,
// khuôn KHKK `:1785`). Không có ca này thì " " chui xuống DB thành một lý do rỗng trông
// như có — đọc log tưởng người ta đã ghi lý do.
// Viết RIÊNG một `[Fact]` theo X-9 (gộp vào T-1 làm vỡ ô đếm `[Fact]` ≥ 13).
// ===================================================================
[Fact]
// T-13: Reason toàn khoảng trắng → xoá OK + ContextNote lưu null.
public async Task DeleteContract_WhitespaceReason_Succeeds_AndStoresNullContextNote()
{
using var f = new ContractDeleteFixture();
var id = await SeedContractAsync(f, ContractPhase.DangSoanThao, f.Owner.UserId);
await DeleteAsync(f, id, " ", f.Owner);
await using var db = f.NewDb();
db.Contracts.Count(c => c.Id == id).Should().Be(0, "khoảng trắng không phải lý do để chặn xoá");
var del = db.ContractChangelogs.AsNoTracking()
.Single(l => l.ContractId == id && l.Action == ChangelogAction.Delete);
del.ContextNote.Should().BeNull(
"trim-to-null: chuỗi trắng phải thành NULL, đừng để log trông như có lý do");
}
}