[CLAUDE] Contract: W7 KHKK GD4 "Hop dong cung" 1-moc — HardCopiesPage x2 + ?type filter + hasSealedCopy badge + 3 test PIN (suite 590/0)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m50s

- BE: ListContractsQuery +ContractType? type (filter server-side F-C1/F-05) + DTO HasSealedCopy derive Attachments.Any(purpose=SealedCopy=3) — append-cuoi default false, 0 policy moi, 0 migration, attachment endpoints 0-diff (O-A giu)
- FE x2 SHA-PAIR: trang /hard-copies (list phase=9 hardcode + badge + panel upload multipart purpose=3 + Y kien cap duyet readonly) + 7 leaf Hdc_* re-point ?type=1..7 (coming-soon 0/0, menu 142 giu)
- Tests: ContractHardCopyTests 3 PIN (phase-only + type-filter Total-server-side + badge derive purpose=3 no-IsDeleted) — probe vi-phan 5/5 phan biet ban hong; suite 587->590/0
- Review: PASS-co-dieu-kien (FLAG-1 untracked da gate; FLAG-2/3 comment da va, SHA-pair C7DCC20B giu; +31/-4 errata BE self-report)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-30 00:17:52 +07:00
parent e16fa14ebd
commit 3119cb9104
12 changed files with 1358 additions and 18 deletions

View File

@ -0,0 +1,397 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.Contracts;
using SolutionErp.Application.Contracts.Dtos;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// [W7 ĐỢT-2 — S161 2026-07-29] Bản cứng `Hdc_*`: lọc theo LOẠI + PHASE, và cờ badge
// "Đã lưu bản cứng".
// Spec: `runs/2026-07-29-S160-khkk-dryrun-plan/spec-wave-w7-ban-cung-hdc-29-07-2026.md` §③-B.
// Timing = **test-after** (đợt-1 BE+FE đã land, suite 587 xanh) ⇒ 3 test này phải XANH ngay.
// Đỏ ở đây = hồi quy thật, KHÔNG được sửa test cho vừa.
//
// 3 tên PIN theo spec (`:27`) — thiếu/đổi 1 chữ là TRƯỢT acceptance:
// T1 `ListHardCopies_ReturnsOnlyContracts_InDaPhatHanhPhase`
// T2 `ListHardCopies_FiltersByContractType` (review F-C1 — chứng `type` LỌC THẬT)
// T3 `HardCopyBadge_DerivesFromSealedCopyAttachment`
//
// ── 4 quyết định hạ tầng (điều kiện để phép đo CÓ NGHĨA) ──────────────────────────
//
// (1) 🔴 Đo bằng CẢ HAI loại actor. Filter `type` nằm NGOÀI khối IDOR
// (`ContractFeatures.cs:327` vs khối `:302-319`). Ai đó dời nó VÀO TRONG nhánh
// `if (!admin)` thì bản chỉ-đo-Director vẫn xanh, bản chỉ-đo-admin vẫn xanh ở chiều
// ngược lại. Nên T1/T2 chạy phép đo trên admin (đo THUẦN cái filter, không bị IDOR
// che) rồi lặp lại trên Director non-admin (vai THẬT của trang bản cứng).
//
// (2) 🔴 Assert `Total` chứ không chỉ `Items`. `CountAsync` đứng SAU mọi `Where`
// (`ContractFeatures.cs:340`) ⇒ `Total` là dấu vân tay của lọc SERVER-SIDE. Một bản
// "lấy pageSize:100 rồi lọc phía client" (đúng thứ spec §②-2b cấm) cho `Items` y hệt
// nhưng `Total` = cả thế giới. Đây là assert phân biệt được hai cài đặt đó.
//
// (3) `TestApplicationDbContext` KHÔNG wire `AuditingInterceptor` ⇒ `Remove()` = xoá CỨNG.
// Khối xoá-mềm ở T3 set thẳng `IsDeleted = true` (bài S155: đo "xoá mềm" trên xoá cứng
// = đo nhầm cơ chế — rào thật là global filter che dòng, không phải dòng biến mất).
//
// (4) Mỗi test dựng `IdentityFixture` RIÊNG (SQLite :memory: riêng) ⇒ thế giới ĐÓNG, nên
// `BeEquivalentTo(<tập đủ>)` mới là phép đo hai chiều (thừa cũng đỏ, thiếu cũng đỏ).
//
// ⚠️ `ContractAttachment : BaseEntity` (`ContractAttachment.cs:13`) — **KHÔNG có `IsDeleted`**,
// config không `HasQueryFilter` (`ContractConfiguration.cs:78-93`), xoá đính kèm là HARD
// delete (`ContractAttachmentFeatures.cs:143`). Nên không có (và không thể có) vế
// `!a.IsDeleted` ở đâu trong file này.
public class ContractHardCopyTests
{
private static readonly DateTime Clock2026 = new(2026, 7, 29, 3, 0, 0, DateTimeKind.Utc);
// ===================================================================
// Seed helpers
// ===================================================================
private static TestCurrentUser As(User u, params string[] roles)
=> new(u.Id, u.FullName, u.Email, roles);
private static async Task<(Supplier sup, Project proj)> SeedMasterAsync(TestApplicationDbContext db)
{
var sup = new Supplier
{
Id = Guid.NewGuid(),
Code = "BTBM",
Name = "NCC Bê tông Bình Minh",
Type = SupplierType.NhaThauPhu,
};
var proj = new Project { Id = Guid.NewGuid(), Code = "FLOCK01", Name = "Dự án FLOCK 01" };
db.Suppliers.Add(sup);
db.Projects.Add(proj);
await db.SaveChangesAsync(CancellationToken.None);
return (sup, proj);
}
/// Khuôn `ContractV2PipelineTests.AddContractAsync` nhưng **nhận `ContractType`** — W7 đo
/// chính trục LOẠI HĐ nên loại không thể hardcode như W6.
private static async Task<Contract> AddContractAsync(
TestApplicationDbContext db,
Supplier sup,
Project proj,
Guid drafterId,
ContractType type,
ContractPhase phase,
string tenHopDong)
{
var c = new Contract
{
Id = Guid.NewGuid(),
Type = type,
Phase = phase,
SupplierId = sup.Id,
ProjectId = proj.Id,
DrafterUserId = drafterId,
TenHopDong = tenHopDong,
GiaTri = 100_000_000m,
};
db.Contracts.Add(c);
await db.SaveChangesAsync(CancellationToken.None);
return c;
}
/// Seed thẳng `ContractAttachments` — entity là `BaseEntity`, không interceptor, không
/// soft-delete. 3 cột `FileName/StoragePath/ContentType` là `IsRequired()`
/// (`ContractConfiguration.cs:84-86`) nên phải có giá trị thật.
private static async Task<ContractAttachment> AddAttachmentAsync(
TestApplicationDbContext db, Contract c, AttachmentPurpose purpose, string? note = null)
{
var att = new ContractAttachment
{
Id = Guid.NewGuid(),
ContractId = c.Id,
FileName = $"{purpose}-{Guid.NewGuid():N}.pdf",
StoragePath = $"uploads/contracts/{c.Id}/{purpose}.pdf",
ContentType = "application/pdf",
FileSize = 1024,
Purpose = purpose,
Note = note,
};
db.ContractAttachments.Add(att);
await db.SaveChangesAsync(CancellationToken.None);
return att;
}
// ===================================================================
// Act helpers — gọi HANDLER THẬT, không mock
// ===================================================================
/// Đúng đường FE 7 leaf `Hdc_*` gọi: `GET /api/contracts?type={1..7}&phase=9`
/// (`ContractsController.cs:25,27` dựng query bằng VỊ TRÍ `(phase, supplierId, projectId, type)`).
private static Task<PagedResult<ContractListItemDto>> ListAsync(
TestApplicationDbContext db, TestCurrentUser actor,
ContractPhase? phase = null, ContractType? type = null)
=> new ListContractsQueryHandler(db, actor)
.Handle(new ListContractsQuery(Phase: phase, Type: type) { PageSize = 200 }, CancellationToken.None);
private static Task<PagedResult<ContractListItemDto>> ListDeletedAsync(
TestApplicationDbContext db, TestCurrentUser actor)
=> new ListDeletedContractsQueryHandler(db, actor)
.Handle(new ListDeletedContractsQuery() { PageSize = 200 }, CancellationToken.None);
private static Task<List<ContractListItemDto>> InboxAsync(
TestApplicationDbContext db, TestCurrentUser actor)
=> new GetMyInboxQueryHandler(db, actor).Handle(new GetMyInboxQuery(), CancellationToken.None);
// ===================================================================
// (T1) Trang bản cứng chỉ liệt HĐ **đã phát hành**. Spec §③-B `:27` tên #1.
//
// Thế giới cố ý rải ĐỦ 8 phase trên CÙNG MỘT LOẠI HĐ, trong đó 3 hàng xóm nguy hiểm:
// • `DangDongDau = 8` — tên của nó CHÍNH LÀ khâu bản cứng b.20 ("đóng dấu"). Một cài
// đặt kiểu "phase bản-cứng" / `Phase >= 8` nuốt gọn nó. Không seed = lỗi vô hình.
// • `TraLai = 98` + `TuChoi = 99` — số LỚN HƠN 9, nên mọi so sánh `>=` sai đều lộ.
// • `ChoDuyet = 10` — cũng lớn hơn 9, và là phase đông dòng nhất trong prod.
//
// 2 HĐ `DaPhatHanh` (không phải 1) ⇒ loại ca "trả đúng một dòng do may".
//
// Actor chính là ADMIN có chủ đích: admin bỏ qua khối IDOR (`ContractFeatures.cs:302`)
// nên thứ DUY NHẤT thu hẹp tập là param `phase` — đo đúng cái filter, không phải đo
// gián tiếp qua quyền. Vế vai THẬT (Director) đo riêng ở khối 3.
// ===================================================================
[Fact]
public async Task ListHardCopies_ReturnsOnlyContracts_InDaPhatHanhPhase()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (sup, proj) = await SeedMasterAsync(db);
var drafter = await fix.CreateUserAsync("w7t1-drafter@test.local", "BCH soạn HĐ", null, []);
var adminUser = await fix.CreateUserAsync("w7t1-admin@test.local", "Quản trị hệ thống", null, [AppRoles.Admin]);
var directorUser = await fix.CreateUserAsync("w7t1-dir@test.local", "Giám đốc ký", null, [AppRoles.Director]);
const ContractType type = ContractType.HopDongThauPhu;
var published1 = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.DaPhatHanh, "HĐ đã phát hành 1");
var published2 = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.DaPhatHanh, "HĐ đã phát hành 2");
var dongDau = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.DangDongDau, "HĐ đang đóng dấu (legacy 8)");
var trinhKy = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.DangTrinhKy, "HĐ đang trình ký (legacy 7)");
var choDuyet = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.ChoDuyet, "HĐ chờ duyệt");
var draft = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.DangSoanThao, "HĐ còn Nháp");
var traLai = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.TraLai, "HĐ bị trả lại");
var tuChoi = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.TuChoi, "HĐ bị từ chối");
var admin = As(adminUser, AppRoles.Admin);
// ---- CHỨNG NHÂN DƯƠNG trước: không lọc ⇒ thấy ĐỦ 8. Thiếu vế này thì mọi assert
// "chỉ có phase 9" ở dưới vẫn xanh khi handler hỏng-luôn và trả rỗng/thiếu.
var all = await ListAsync(db, admin);
all.Items.Select(i => i.Id).Should().BeEquivalentTo(
new[] { published1.Id, published2.Id, dongDau.Id, trinhKy.Id, choDuyet.Id, draft.Id, traLai.Id, tuChoi.Id },
"admin không lọc phải thấy toàn bộ thế giới 8 HĐ — đây là mốc để biết việc thu hẹp "
+ "ở dưới đến TỪ PARAM chứ không phải từ một handler đang hỏng");
all.Total.Should().Be(8);
// ---- Vế chính: `?phase=9` ⇒ ĐÚNG tập 2 HĐ đã phát hành, không hơn không kém.
var hardCopies = await ListAsync(db, admin, phase: ContractPhase.DaPhatHanh);
hardCopies.Items.Select(i => i.Id).Should().BeEquivalentTo(new[] { published1.Id, published2.Id },
"trang bản cứng (GĐ4) chỉ theo dõi HĐ ĐÃ PHÁT HÀNH; lọt `DangDongDau` (phase 8 — "
+ "tên đúng là 'đóng dấu', hàng xóm dễ nhầm nhất) hoặc `ChoDuyet/TraLai/TuChoi` "
+ "(đều là số LỚN HƠN 9) nghĩa là điều kiện phase đang so sánh kiểu `>=` chứ không phải `==`");
hardCopies.Total.Should().Be(2, "Total tính SAU filter — bằng 8 nghĩa là đang lọc phía client sau khi lấy cả trang");
// Bất biến ở mức TỪNG DÒNG (acceptance §③-B `:30` 'mọi item phase==9').
hardCopies.Items.Should().OnlyContain(i => i.Phase == ContractPhase.DaPhatHanh);
hardCopies.Items.Should().HaveCount(2);
// ---- Khối 3: VAI THẬT (acceptance §③-B `:31`) — Director non-admin, KHÔNG rỗng.
var director = As(directorUser, AppRoles.Director);
director.Roles.Should().NotContain(AppRoles.Admin, "vai thật của trang bản cứng KHÔNG phải admin");
// Cặp 1-biến trên CHÍNH actor đó: bỏ `phase` ⇒ thấy thêm HĐ `DangTrinhKy` (vế role
// legacy `GetEligiblePhases(Director)`); thêm `phase=9` ⇒ đúng HĐ đó rụng đi. Nếu
// hai lần gọi ra cùng một tập thì param `phase` chẳng làm gì cả với vai này.
var dirNoFilter = await ListAsync(db, director);
dirNoFilter.Items.Select(i => i.Id).Should().Contain(trinhKy.Id,
"Director thấy HĐ `DangTrinhKy` theo vế role legacy — mốc đối chứng cho lần lọc dưới");
var dirHardCopies = await ListAsync(db, director, phase: ContractPhase.DaPhatHanh, type: type);
dirHardCopies.Items.Should().NotBeEmpty(
"vai bản-cứng THẬT phải thấy được danh sách (acceptance đòi 'không rỗng/không 403')");
dirHardCopies.Items.Select(i => i.Id).Should().BeEquivalentTo(new[] { published1.Id, published2.Id });
dirHardCopies.Items.Select(i => i.Id).Should().NotContain(trinhKy.Id,
"cùng actor, chỉ thêm ĐÚNG MỘT biến `phase=9`, HĐ đang trình ký phải rụng — chứng "
+ "việc thu hẹp là của param, không phải của quyền");
dirHardCopies.Total.Should().Be(2);
}
// ===================================================================
// (T2) Param `type` LỌC THẬT — 2 loại ra 2 danh sách KHÁC NHAU. Spec §③-B `:27` tên #2,
// acceptance `:30` (review F-C1).
//
// Lỗi mà test này tồn tại để bắt: 7 leaf `Hdc_*` hiện y hệt nhau vì BE bỏ qua `type`
// (trước W7 `GET /api/contracts` KHÔNG CÓ param này) — nhìn trên UI thì mỗi trang vẫn
// "có dữ liệu", trông như chạy đúng.
//
// Thế giới trộn 3 loại × 2 phase để 2 filter phải AND với nhau chứ không đè nhau.
// ===================================================================
[Fact]
public async Task ListHardCopies_FiltersByContractType()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (sup, proj) = await SeedMasterAsync(db);
var drafter = await fix.CreateUserAsync("w7t2-drafter@test.local", "BCH soạn HĐ", null, []);
var adminUser = await fix.CreateUserAsync("w7t2-admin@test.local", "Quản trị hệ thống", null, [AppRoles.Admin]);
var hrUser = await fix.CreateUserAsync("w7t2-hra@test.local", "HC-NS đóng dấu", null, [AppRoles.HrAdmin]);
var t1a = await AddContractAsync(db, sup, proj, drafter.Id, ContractType.HopDongThauPhu, ContractPhase.DaPhatHanh, "Thầu phụ A");
var t1b = await AddContractAsync(db, sup, proj, drafter.Id, ContractType.HopDongThauPhu, ContractPhase.DaPhatHanh, "Thầu phụ B");
var t2 = await AddContractAsync(db, sup, proj, drafter.Id, ContractType.HopDongGiaoKhoan, ContractPhase.DaPhatHanh, "Giao khoán A");
var t5 = await AddContractAsync(db, sup, proj, drafter.Id, ContractType.HopDongMuaBan, ContractPhase.DaPhatHanh, "Mua bán A");
var t1Pending = await AddContractAsync(db, sup, proj, drafter.Id, ContractType.HopDongThauPhu, ContractPhase.ChoDuyet, "Thầu phụ chờ duyệt");
var admin = As(adminUser, AppRoles.Admin);
// ---- CHỨNG NHÂN DƯƠNG: `?phase=9` KHÔNG kèm `type` ⇒ đủ 4 HĐ của 3 loại.
var allPublished = await ListAsync(db, admin, phase: ContractPhase.DaPhatHanh);
allPublished.Items.Select(i => i.Id).Should().BeEquivalentTo(new[] { t1a.Id, t1b.Id, t2.Id, t5.Id },
"bỏ `type` thì trang bản cứng thấy cả 3 loại — mốc để chứng việc tách nhỏ ở dưới là do PARAM");
allPublished.Total.Should().Be(4);
// ---- Vế chính: 2 loại ⇒ 2 danh sách KHÁC NHAU (literal acceptance `:30`).
var listType1 = await ListAsync(db, admin, phase: ContractPhase.DaPhatHanh, type: ContractType.HopDongThauPhu);
var listType2 = await ListAsync(db, admin, phase: ContractPhase.DaPhatHanh, type: ContractType.HopDongGiaoKhoan);
listType1.Items.Select(i => i.Id).Should().BeEquivalentTo(new[] { t1a.Id, t1b.Id },
"leaf 'HĐ Thầu phụ - Bảng cứng' chỉ được thấy HĐ Thầu phụ; thấy đủ 4 nghĩa là "
+ "`type` bị bỏ qua và 7 leaf `Hdc_*` sẽ hiện TRÙNG NHAU (đúng lỗi F-C1)");
listType2.Items.Select(i => i.Id).Should().BeEquivalentTo(new[] { t2.Id });
listType1.Items.Select(i => i.Id).Should().NotIntersectWith(listType2.Items.Select(i => i.Id),
"acceptance đòi ĐÚNG chữ '2 loại KHÁC NHAU ra 2 danh sách KHÁC NHAU'");
// Bất biến từng dòng — chặn ca "lọt 1 dòng loại khác ở trang sau".
listType1.Items.Should().OnlyContain(i => i.Type == ContractType.HopDongThauPhu);
listType2.Items.Should().OnlyContain(i => i.Type == ContractType.HopDongGiaoKhoan);
// ---- 🔴 `Total` = dấu vân tay của lọc SERVER-SIDE (xem ghi chú hạ tầng (2)).
listType1.Total.Should().Be(2,
"`Total` đếm SAU `Where` (`ContractFeatures.cs:340`) ⇒ phải là 2 chứ không phải 4/5. "
+ "Bằng 4 nghĩa là dòng đã lấy về rồi mới lọc — đúng cách làm mà spec §②-2b cấm "
+ "('KHÔNG lọc client-side pageSize:100', trần 100 dòng/loại)");
listType2.Total.Should().Be(1);
// ---- `type` và `phase` AND độc lập: `type=1` không kèm phase ⇒ có cả HĐ ChoDuyet.
var type1AnyPhase = await ListAsync(db, admin, type: ContractType.HopDongThauPhu);
type1AnyPhase.Items.Select(i => i.Id).Should().BeEquivalentTo(new[] { t1a.Id, t1b.Id, t1Pending.Id },
"`type` chỉ được lọc LOẠI; nó mà tự kéo theo phase thì hai filter đang dính nhau");
// ---- Lặp phép đo với VAI THẬT non-admin (xem ghi chú hạ tầng (1)): nếu ai đó dời
// dòng filter `type` vào trong khối `if (!admin)` thì khối admin ở trên đỏ; dời
// ra chỗ chỉ chạy cho admin thì khối này đỏ. Hai khối khoá cả hai chiều.
var hra = As(hrUser, AppRoles.HrAdmin);
hra.Roles.Should().NotContain(AppRoles.Admin);
var hraType2 = await ListAsync(db, hra, phase: ContractPhase.DaPhatHanh, type: ContractType.HopDongGiaoKhoan);
hraType2.Items.Select(i => i.Id).Should().BeEquivalentTo(new[] { t2.Id },
"vai bản-cứng HrAdmin (b.20 đóng dấu) cũng phải nhận đúng 1 loại — filter `type` "
+ "sống ở CẢ nhánh non-admin");
hraType2.Total.Should().Be(1);
// ---- Ca ÂM acceptance `:32`: loại chưa có HĐ phát hành ⇒ rỗng, KHÔNG văng.
var emptyLeaf = await ListAsync(db, admin, phase: ContractPhase.DaPhatHanh,
type: ContractType.HopDongNguyenTacDichVu);
emptyLeaf.Items.Should().BeEmpty("leaf loại chưa có HĐ nào phát hành ⇒ empty-state, không phải lỗi");
emptyLeaf.Total.Should().Be(0, "và Total phải là 0 để FE phân trang không vẽ trang ma");
}
// ===================================================================
// (T3) Badge "Đã lưu bản cứng" derive từ attachment `SealedCopy`. Spec §③-B `:27` tên #3.
//
// 🔴 Ca âm đắt nhất là `ScannedSigned = 2`: spec `:18` + gotcha #71 CẤM derive từ nó
// (=2 nghĩa "scan HĐ có chữ ký NCC ở phase `DangInKy`" — chữ ký NCC, chưa đóng dấu,
// chưa phải bộ cứng đã lưu). Cài `Purpose >= ScannedSigned` hay `Attachments.Any()`
// (hai cách viết tự nhiên nhất) cho T3 xanh ở mọi ca KHÁC và chỉ vỡ ở đây.
//
// Cả 3 khối đều xoay quanh nguyên tắc: mọi `false` phải có chứng nhân là seed CÓ THẬT.
// ===================================================================
[Fact]
public async Task HardCopyBadge_DerivesFromSealedCopyAttachment()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var (sup, proj) = await SeedMasterAsync(db);
var drafter = await fix.CreateUserAsync("w7t3-drafter@test.local", "BCH soạn HĐ", null, []);
var adminUser = await fix.CreateUserAsync("w7t3-admin@test.local", "Quản trị hệ thống", null, [AppRoles.Admin]);
var admin = As(adminUser, AppRoles.Admin);
const ContractType type = ContractType.HopDongThauPhu;
var cSealed = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.DaPhatHanh, "HĐ đã lưu bản cứng");
var cOther = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.DaPhatHanh, "HĐ chỉ có file khác");
var cNone = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.DaPhatHanh, "HĐ chưa có file nào");
await AddAttachmentAsync(db, cSealed, AttachmentPurpose.SealedCopy, note: "2 bộ gốc, lưu tủ P.HC");
await AddAttachmentAsync(db, cOther, AttachmentPurpose.DraftExport);
await AddAttachmentAsync(db, cOther, AttachmentPurpose.ScannedSigned); // 🔴 gotcha #71
await AddAttachmentAsync(db, cOther, AttachmentPurpose.Other);
// ---- Sanity chống vacuous: `false` của `cOther` phải là "lọc đúng", KHÔNG phải
// "seed hỏng nên chẳng có gì". Đếm từ DB, không tin lời gọi seed.
(await db.ContractAttachments.CountAsync(a => a.ContractId == cOther.Id))
.Should().Be(3, "ca âm chỉ có nghĩa khi HĐ đó THỰC SỰ có 3 đính kèm (chỉ thiếu đúng purpose=3)");
(await db.ContractAttachments.CountAsync(a => a.ContractId == cNone.Id)).Should().Be(0);
// ---- Vế chính.
var badges = (await ListAsync(db, admin, phase: ContractPhase.DaPhatHanh))
.Items.ToDictionary(i => i.Id, i => i.HasSealedCopy);
badges.Should().HaveCount(3, "cả 3 HĐ phải nằm trong danh sách thì mới đọc được cờ của chúng");
badges[cSealed.Id].Should().BeTrue(
"có đính kèm `SealedCopy` (=3) ⇒ badge 'Đã lưu bản cứng' phải bật — đây là mốc DUY NHẤT "
+ "của mô hình 1-mốc (@S161): upload scan bộ cứng xong là xem như xong");
badges[cOther.Id].Should().BeFalse(
"HĐ này có `DraftExport(1)` + `ScannedSigned(2)` + `Other(99)` nhưng KHÔNG có `SealedCopy(3)`. "
+ "Bật cờ ở đây = đang derive từ `ScannedSigned` (scan chữ ký NCC ở `DangInKy` — gotcha #71, "
+ "spec `:18` cấm) hoặc từ 'có đính kèm bất kỳ'; cả hai đều báo 'đã lưu bản cứng' cho HĐ "
+ "chưa hề có bộ cứng nào");
badges[cNone.Id].Should().BeFalse("không có đính kèm nào thì không thể có bản cứng");
// ---- LẬT 1 BIẾN: thêm đúng 1 đính kèm purpose=3 cho `cOther` ⇒ cờ phải bật.
// Đây là chứng nhân dương cho toàn bộ các `BeFalse` ở trên: nếu cờ hỏng-luôn
// (luôn false) thì mọi assert âm vẫn xanh, và chỉ ca này bắt được.
await AddAttachmentAsync(db, cOther, AttachmentPurpose.SealedCopy);
var afterUpload = (await ListAsync(db, admin, phase: ContractPhase.DaPhatHanh))
.Items.Single(i => i.Id == cOther.Id);
afterUpload.HasSealedCopy.Should().BeTrue(
"cùng HĐ, cùng actor, đổi ĐÚNG MỘT biến (upload thêm 1 file purpose=3) ⇒ badge lật true. "
+ "Nếu vẫn false thì mấy assert âm ở trên xanh vì cờ chết cứng, không phải vì lọc đúng");
// ---- Khối B — GHIM CAVEAT 1 (lane BE `sub-implementer-backend-0.md` §8 điểm 2):
// cờ KHÔNG xét phase. HĐ `ChoDuyet` có purpose=3 vẫn `true`. Đây là hành vi CỐ Ý
// hiện tại (FE tự lọc `phase=9`), không phải bug — ghim để lần sau ai đổi thì
// biết mình đang ĐỔI HỢP ĐỒNG chứ không phải sửa vặt.
var cPending = await AddContractAsync(db, sup, proj, drafter.Id, type, ContractPhase.ChoDuyet, "HĐ chờ duyệt mà đã có scan");
await AddAttachmentAsync(db, cPending, AttachmentPurpose.SealedCopy);
(await ListAsync(db, admin)).Items.Single(i => i.Id == cPending.Id).HasSealedCopy.Should().BeTrue(
"cờ derive THUẦN theo purpose, không kèm điều kiện phase (`ContractFeatures.cs:355`) ⇒ "
+ "trách nhiệm lọc `phase=9` nằm ở phía gọi (7 leaf `Hdc_*`)");
// ---- Khối C — GHIM CAVEAT 2 (§8 điểm 1 + `ContractDtos.cs:18-25`): cờ CHỈ được
// derive ở màn danh sách. Màn "Đã xóa" và "Hộp thư" dựng DTO bằng 12 arg nên
// nhận default `false` — dù HĐ ĐANG CÓ đính kèm purpose=3. Test theo CODE.
cSealed.IsDeleted = true; // KHÔNG `Remove()`: fixture không wire interceptor ⇒ Remove = xoá CỨNG (S155)
cSealed.DeletedAt = Clock2026;
await db.SaveChangesAsync(CancellationToken.None);
(await db.ContractAttachments.CountAsync(a => a.ContractId == cSealed.Id)).Should().Be(1,
"đính kèm vẫn còn nguyên (xoá mềm HĐ không đụng bảng đính kèm) — nếu nó biến mất thì "
+ "assert dưới xanh vì lý do khác hẳn");
(await ListDeletedAsync(db, admin)).Items.Single(i => i.Id == cSealed.Id).HasSealedCopy
.Should().BeFalse(
"màn 'Đã xóa' CỐ Ý không derive cờ (DTO default `= false`, `ContractDtos.cs:25`) dù HĐ "
+ "vẫn còn đính kèm purpose=3. Đây là ranh giới scope W7 đang được ghim, không phải "
+ "phép đo nói rằng HĐ đó không có bản cứng");
(await InboxAsync(db, admin)).Single(i => i.Id == cPending.Id).HasSealedCopy.Should().BeFalse(
"cùng lý do ở 'Hộp thư' — HĐ `cPending` vừa được chứng minh là `true` ở màn danh sách, "
+ "nên `false` ở đây đúng là do KHÔNG derive, không phải do HĐ khác");
}
}