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