[CLAUDE] Supplier: import v2 — Mig 64 publish/draft + dedup-MST + template + file mẫu
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m14s

- Mig 64 AddSupplierPublishState: +IsPublic (draft/public); backfill 22 prod->public; filtered-unique doi [Code]<>'' (cho phep nhieu nhap Code=''); no new table (89).
- Import v2: dedup MST-primary + Code-backstop (chong 500 unique-violation); re-bake 30 token header THAT byte-exact (LayoutValid khop file that); nhap Ma NCC per-row (=Code); thieu Ma NCC->nhap (IsPublic=false); MST thieu->canh bao mem (MstMissing).
- PublishSupplierCommand rieng (ne #73 clobber); GET /suppliers/import/template (BE-gen xlsx 30-col).
- FE 2-app SHA-mirror: badge Public/Nhap; nut Cong bo; filter; nut Tai file mau; cot Ma editable; canh bao MST. Picker PE + tao HD loc published=true (an nhap -> ma HD khong dinh Code rong). CreateSupplier set IsPublic=true.
- authz D3: import/preview/confirm/template/publish = Policy Suppliers.Update (khop FE PermissionGuard, het 403).
- Tests +19 (477 PASS): dedup T1-T7, publish-guard, list-filter, all-or-nothing, LayoutValid. Fix null-Code NRE (path R4 loi).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-12 19:03:37 +07:00
parent 41f29acf68
commit 5fa11b588a
28 changed files with 7659 additions and 105 deletions

View File

@ -0,0 +1,155 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Master.Suppliers.Commands.PublishSupplier;
using SolutionErp.Application.Master.Suppliers.Queries.GetSupplier;
using SolutionErp.Application.Master.Suppliers.Queries.ListSuppliers;
using SolutionErp.Domain.Master;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// ============================================================================
// Supplier import v2 (S113) — PublishSupplierCommand + ListSuppliers/GetSupplier IsPublic.
// Publish-gate = SECURITY/correctness (cổng công bố NCC) → test-before-merge (docs/rules.md §7).
// Test theo CODE trên đĩa (S34) — handlers chỉ cần IApplicationDbContext → new trực tiếp với fix.Db.
//
// 🔑 PublishSupplierCommand (KHÔNG dùng UpdateSupplier, tránh #73 clobber):
// - MaNcc gửi (non-null): set Code=Trim; unique-check `x.Code == newCode && x.Id != id` (khi len>0
// & khác Code cũ) → ConflictException nếu trùng.
// - Gate D2: Publish=true ∧ Code rỗng → ConflictException "Cần Mã NCC...".
// - IsPublic = Publish.
//
// ⚠️ Unique-check publish dùng `x.Code == newCode` (DB collation), KHÁC import service
// (OrdinalIgnoreCase in-mem). SQLite BINARY → chỉ bắt trùng ĐÚNG hoa/thường. Prod SQL Server
// CI-collation mới bắt CI-dup. Test T9b dùng trùng ĐÚNG-case (deterministic trên SQLite).
// (Observation, KHÔNG phải bug — prod collation CI xử lý; flag để biết asymmetry.)
// ============================================================================
public class SupplierPublishAndListTests
{
private static Supplier NewSupplier(string code, string name, bool isPublic,
SupplierType type = SupplierType.NhaCungCap) =>
new() { Id = Guid.NewGuid(), Code = code, Name = name, Type = type, IsPublic = isPublic };
// ---- T9 — publish có Mã NCC → set Code + IsPublic=true ----
[Fact]
public async Task PublishSupplier_WithMaNcc_SetsCode_AndIsPublicTrue()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var draft = NewSupplier("", "Draft NCC", isPublic: false); // nháp, chưa có Mã NCC
db.Suppliers.Add(draft);
await db.SaveChangesAsync(CancellationToken.None);
await new PublishSupplierCommandHandler(db)
.Handle(new PublishSupplierCommand(draft.Id, MaNcc: "NEW01", Publish: true), CancellationToken.None);
var reloaded = await db.Suppliers.SingleAsync(x => x.Id == draft.Id);
reloaded.Code.Should().Be("NEW01", "MaNcc gửi → set Code");
reloaded.IsPublic.Should().BeTrue("Publish=true + có Code → công bố");
}
// ---- T9b — publish với Mã NCC trùng (đúng hoa/thường) NCC khác → ConflictException ----
[Fact]
public async Task PublishSupplier_MaNccCollidesExactCaseWithOther_ThrowsConflict_NoChange()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var other = NewSupplier("TAKEN", "Other", isPublic: true);
var draft = NewSupplier("", "Draft", isPublic: false);
db.Suppliers.AddRange(other, draft);
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await new PublishSupplierCommandHandler(db)
.Handle(new PublishSupplierCommand(draft.Id, MaNcc: "TAKEN", Publish: true), CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*đã tồn tại*", "Mã NCC trùng NCC khác → chặn (unique-check)");
var reloaded = await db.Suppliers.SingleAsync(x => x.Id == draft.Id);
reloaded.Code.Should().Be("", "throw trước khi gán → draft KHÔNG đổi");
reloaded.IsPublic.Should().BeFalse();
}
// ---- T10 — publish khi Code rỗng (không gửi MaNcc) → ConflictException "Cần Mã NCC" ----
[Fact]
public async Task PublishSupplier_PublishTrue_ButCodeEmpty_ThrowsConflict_CanMaNcc()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var draft = NewSupplier("", "Draft NCC", isPublic: false);
db.Suppliers.Add(draft);
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await new PublishSupplierCommandHandler(db)
.Handle(new PublishSupplierCommand(draft.Id, MaNcc: null, Publish: true), CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*Cần Mã NCC*", "gate D2: công bố bắt buộc có Mã NCC");
(await db.Suppliers.SingleAsync(x => x.Id == draft.Id)).IsPublic
.Should().BeFalse("gate chặn → vẫn nháp");
}
// ---- T11 — unpublish (Publish=false) LUÔN cho, IsPublic=false, giữ Code ----
[Fact]
public async Task PublishSupplier_Unpublish_AlwaysAllowed_SetsIsPublicFalse_KeepsCode()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var pub = NewSupplier("KEEP", "Public NCC", isPublic: true);
db.Suppliers.Add(pub);
await db.SaveChangesAsync(CancellationToken.None);
await new PublishSupplierCommandHandler(db)
.Handle(new PublishSupplierCommand(pub.Id, MaNcc: null, Publish: false), CancellationToken.None);
var reloaded = await db.Suppliers.SingleAsync(x => x.Id == pub.Id);
reloaded.IsPublic.Should().BeFalse("ẩn luôn được, KHÔNG cần Mã NCC");
reloaded.Code.Should().Be("KEEP", "unpublish KHÔNG đụng Code");
}
// ---- T12 — ListSuppliers Published filter: true / false / null=all ----
[Fact]
public async Task ListSuppliers_PublishedFilter_FiltersByIsPublic_NullReturnsAll()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
db.Suppliers.AddRange(
NewSupplier("P1", "Pub 1", isPublic: true),
NewSupplier("P2", "Pub 2", isPublic: true),
NewSupplier("", "Draft 1", isPublic: false));
await db.SaveChangesAsync(CancellationToken.None);
var handler = new ListSuppliersQueryHandler(db);
var published = await handler.Handle(new ListSuppliersQuery(Published: true) { PageSize = 100 }, CancellationToken.None);
published.Total.Should().Be(2, "Published=true → chỉ NCC đã công bố");
published.Items.Should().OnlyContain(x => x.IsPublic);
var drafts = await handler.Handle(new ListSuppliersQuery(Published: false) { PageSize = 100 }, CancellationToken.None);
drafts.Total.Should().Be(1, "Published=false → chỉ nháp");
drafts.Items.Should().OnlyContain(x => !x.IsPublic);
var all = await handler.Handle(new ListSuppliersQuery(Published: null) { PageSize = 100 }, CancellationToken.None);
all.Total.Should().Be(3, "null → tất cả (admin quản lý thấy cả nháp)");
}
// ---- T13 — SupplierDto.IsPublic project đúng (GetSupplier single-item projection) ----
[Fact]
public async Task GetSupplier_ProjectsIsPublic_ForBothPublicAndDraft()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var pub = NewSupplier("G1", "Public", isPublic: true);
var draft = NewSupplier("", "Draft", isPublic: false);
db.Suppliers.AddRange(pub, draft);
await db.SaveChangesAsync(CancellationToken.None);
var handler = new GetSupplierQueryHandler(db);
(await handler.Handle(new GetSupplierQuery(pub.Id), CancellationToken.None)).IsPublic
.Should().BeTrue("DTO project IsPublic=true cho NCC công bố");
(await handler.Handle(new GetSupplierQuery(draft.Id), CancellationToken.None)).IsPublic
.Should().BeFalse("DTO project IsPublic=false cho nháp");
}
}