[CLAUDE] PurchaseEvaluation: PE gắn Hạng mục công việc (Mig 49) + mở quyền Pe all-role + menu Cá nhân + khóa 14 demo user
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m24s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m24s
Sếp chốt deadline 15:00 (Zalo 11:02-11:17): flow tạo phiếu chọn quy trình → dự án → HẠNG MỤC → NCC/TP; phiếu dạng «Dự án – Hạng mục»; all-user thấy Duyệt NCC + master config; clear data cũ. - Mig 49 AddWorkItemToPurchaseEvaluation: PE.WorkItemId Guid? loose-Guid + index (KHÔNG FK vật lý — convention PE, database-agent design). Validator NotEmpty (create) + FK-guard AnyAsync(IsActive) → Conflict + UpdateDraft NULL-SAFE (client không gửi → giữ, chống null-hóa bug-class S42). 3 projection ListItemDto LEFT-join WorkItems. - FE ×2 app: PeWorkspaceCreateView select «c. Hạng mục *» + PeHeaderForm (load existing + PUT gửi lại, SHA256 IDENTICAL) + PeDetailTabs (header «Dự án – Hạng mục» + FormRow + inline khóa) + types. Route reuse /catalogs/work-items. - Perm: SeedAllRolesReviewReadPermissionsAsync extend Pe_* 11 key (factory — Pe leaf không nằm All) CanRead+CanCreate upgrade-only mọi role; PeWf_*/AwV2 GIỮ Admin. HRM/Office/Master/Catalogs CanRead (S57). Master write-lock Admin,CatalogManager ×3 controller. - Menu «Cá nhân» (Personal root 30, mirror Puro) + Chấm công re-parent + HrmConfig→Master + parentBackfill idempotent + admin bỏ ẩn Master (đảo S29). - LockDemoSampleUsersAsync: khóa 14/16 sample (GIỮ nv.cao+nv.truong IT-pool + catalog.manager) — ungated idempotent, IsActive=0+Lockout+SecurityStamp rotate. - Tests +12 PeWorkItemGuardTests (validator/FK-guard/null-safe) → 240 PASS. npm ×2 + BE 0W/0E. - Excel (3) đối chiếu: 62/71/3 identical S55 — no data change. - Gate: em main evidence-checklist (2 reviewer-spawn die-0-byte — resume-kill; backstop 12 guard-test + authz-key/role-string/Mig-49 evidence-lệnh). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,369 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.PurchaseEvaluations;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Domain.Master.Catalogs;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
using SolutionErp.Infrastructure.Tests.Services; // NoOpNotificationService (reuse internal helper)
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// [Mig 49 S57bis] PE gắn WorkItemId (hạng mục công việc) — loose-Guid scalar (KHÔNG
|
||||
// FK vật lý, KHÔNG navigation — convention PE giống ProjectId/SelectedSupplierId).
|
||||
// Test guard 3 trục theo CODE đã land (single source of truth):
|
||||
// 1. CreatePurchaseEvaluationCommandValidator — RuleFor(WorkItemId).NotEmpty()
|
||||
// 2. CreatePurchaseEvaluationCommandHandler — FK-invariant guard
|
||||
// db.WorkItems.AnyAsync(w.Id==x && w.IsActive) → ConflictException
|
||||
// 3. UpdatePurchaseEvaluationDraftCommandHandler — null-safe partial update
|
||||
// (request.WorkItemId is null → GIỮ entity.WorkItemId; bug-class S42 picker
|
||||
// null-hoá từ client cũ / inline-edit không gửi field).
|
||||
//
|
||||
// Create handler 4 dep (db + ICurrentUser + workflow svc + codeGen) instantiate
|
||||
// thật trên SQLite (codeGen Serializable-tx = non-issue SQLite — proven S52).
|
||||
// UpdateDraft handler chỉ 2 dep (db + ICurrentUser) → nhẹ.
|
||||
public class PeWorkItemGuardTests
|
||||
{
|
||||
private sealed class FakeCurrentUser : ICurrentUser
|
||||
{
|
||||
public Guid? UserId { get; init; } = Guid.NewGuid();
|
||||
public string? Email { get; init; } = "drafter@test.local";
|
||||
public string? FullName { get; init; } = "Drafter Test";
|
||||
public IReadOnlyList<string> Roles { get; init; } = new[] { AppRoles.Drafter };
|
||||
public bool IsAuthenticated => UserId is not null;
|
||||
}
|
||||
|
||||
// Build full Create handler stack từ IdentityFixture (db + um cho workflow svc).
|
||||
private static CreatePurchaseEvaluationCommandHandler BuildCreateHandler(
|
||||
TestApplicationDbContext db, UserManager<User> um, ICurrentUser currentUser)
|
||||
{
|
||||
var dt = new FixedDateTime(new DateTime(2026, 6, 11, 0, 0, 0, DateTimeKind.Utc));
|
||||
var notify = new NoOpNotificationService();
|
||||
var workflow = new PurchaseEvaluationWorkflowService(db, dt, notify, um);
|
||||
var codeGen = new PurchaseEvaluationCodeGenerator(db, dt);
|
||||
return new CreatePurchaseEvaluationCommandHandler(db, currentUser, workflow, codeGen);
|
||||
}
|
||||
|
||||
private static async Task<Project> SeedProjectAsync(TestApplicationDbContext db)
|
||||
{
|
||||
var p = new Project { Id = Guid.NewGuid(), Code = "PRJ-WI", Name = "Dự án test WorkItem" };
|
||||
db.Projects.Add(p);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return p;
|
||||
}
|
||||
|
||||
private static async Task<WorkItem> SeedWorkItemAsync(
|
||||
TestApplicationDbContext db, string code, bool isActive = true)
|
||||
{
|
||||
var wi = new WorkItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = code,
|
||||
Name = "Hạng mục " + code,
|
||||
IsActive = isActive,
|
||||
};
|
||||
db.WorkItems.Add(wi);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return wi;
|
||||
}
|
||||
|
||||
private static CreatePurchaseEvaluationCommand BuildCreateCommand(
|
||||
Guid projectId, Guid? workItemId)
|
||||
=> new(
|
||||
Type: PurchaseEvaluationType.DuyetNcc,
|
||||
TenGoiThau: "Gói thầu test",
|
||||
ProjectId: projectId,
|
||||
DepartmentId: null,
|
||||
DiaDiem: null,
|
||||
MoTa: null,
|
||||
PaymentTerms: null,
|
||||
BudgetId: null,
|
||||
BudgetManualName: null,
|
||||
BudgetManualAmount: null,
|
||||
ApprovalWorkflowId: null,
|
||||
WorkItemId: workItemId);
|
||||
|
||||
// ============================================================
|
||||
// 1. VALIDATOR — RuleFor(WorkItemId).NotEmpty()
|
||||
// ============================================================
|
||||
|
||||
[Fact]
|
||||
public void Validator_WorkItemIdNull_IsInvalid_WithErrorOnWorkItemId()
|
||||
{
|
||||
var validator = new CreatePurchaseEvaluationCommandValidator();
|
||||
var cmd = BuildCreateCommand(Guid.NewGuid(), workItemId: null);
|
||||
|
||||
var result = validator.Validate(cmd);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.PropertyName == nameof(CreatePurchaseEvaluationCommand.WorkItemId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_WorkItemIdEmptyGuid_PassesValidator_ButHandlerFkGuardCatches()
|
||||
{
|
||||
// ⚠️ SPEC-DRIFT NOTE (test theo CODE — S34 rule): WorkItemId là `Guid?`
|
||||
// (nullable). FluentValidation 7.2 `NotEmpty()` trên nullable-Guid chỉ check
|
||||
// != default(Guid?) == null — KHÔNG check Guid.Empty. Nên Guid.Empty (non-null)
|
||||
// PASS validator. Đây KHÔNG phải lỗ hổng thực tế: create handler FK-guard
|
||||
// `db.WorkItems.AnyAsync(w.Id==Guid.Empty && w.IsActive)` luôn false → throw
|
||||
// ConflictException. Defense-in-depth: validator chặn null, handler chặn
|
||||
// bogus/empty/inactive. Test này LOCK behavior hiện tại để nếu ai đổi sang
|
||||
// non-nullable Guid hoặc thêm GuidExtensions.NotEmpty → đỏ → review chủ đích.
|
||||
var validator = new CreatePurchaseEvaluationCommandValidator();
|
||||
var cmd = BuildCreateCommand(Guid.NewGuid(), workItemId: Guid.Empty);
|
||||
|
||||
var result = validator.Validate(cmd);
|
||||
|
||||
result.Errors.Should().NotContain(
|
||||
e => e.PropertyName == nameof(CreatePurchaseEvaluationCommand.WorkItemId),
|
||||
"NotEmpty() trên Guid? chỉ bắt null, KHÔNG bắt Guid.Empty — handler FK-guard mới chặn");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_WorkItemIdPresent_NoErrorOnWorkItemId()
|
||||
{
|
||||
// Chỉ assert rule WorkItemId pass — command còn lại đã hợp lệ ở BuildCreateCommand
|
||||
// nên result.IsValid=true; nhưng narrow assert vào property để test đúng rule này.
|
||||
var validator = new CreatePurchaseEvaluationCommandValidator();
|
||||
var cmd = BuildCreateCommand(Guid.NewGuid(), workItemId: Guid.NewGuid());
|
||||
|
||||
var result = validator.Validate(cmd);
|
||||
|
||||
result.Errors.Should().NotContain(e => e.PropertyName == nameof(CreatePurchaseEvaluationCommand.WorkItemId));
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. CREATE HANDLER — FK-invariant guard
|
||||
// ============================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WorkItemBogusGuid_ThrowsConflict()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var project = await SeedProjectAsync(db);
|
||||
var handler = BuildCreateHandler(db, um, new FakeCurrentUser());
|
||||
|
||||
// WorkItemId không tồn tại trong WorkItems → guard fail.
|
||||
var cmd = BuildCreateCommand(project.Id, workItemId: Guid.NewGuid());
|
||||
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*Hạng mục công việc không tồn tại hoặc ngưng hoạt động*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WorkItemEmptyGuid_ThrowsConflict_HandlerDefenseInDepth()
|
||||
{
|
||||
// Chứng minh claim ở Validator_WorkItemIdEmptyGuid_PassesValidator: validator
|
||||
// cho Guid.Empty qua, nhưng handler FK-guard (`is Guid wiId` true cho Empty +
|
||||
// AnyAsync false) chặn → ConflictException. Defense-in-depth thực sự hoạt động.
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var project = await SeedProjectAsync(db);
|
||||
var handler = BuildCreateHandler(db, um, new FakeCurrentUser());
|
||||
|
||||
var cmd = BuildCreateCommand(project.Id, workItemId: Guid.Empty);
|
||||
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*Hạng mục công việc không tồn tại hoặc ngưng hoạt động*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WorkItemInactive_ThrowsConflict()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var project = await SeedProjectAsync(db);
|
||||
var inactive = await SeedWorkItemAsync(db, "WI-INACTIVE", isActive: false);
|
||||
var handler = BuildCreateHandler(db, um, new FakeCurrentUser());
|
||||
|
||||
// WorkItem tồn tại nhưng IsActive=false → guard (w.IsActive) loại → Conflict.
|
||||
var cmd = BuildCreateCommand(project.Id, workItemId: inactive.Id);
|
||||
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*Hạng mục công việc không tồn tại hoặc ngưng hoạt động*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WorkItemActive_Succeeds_AndPersistsWorkItemId()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var project = await SeedProjectAsync(db);
|
||||
var active = await SeedWorkItemAsync(db, "WI-ACTIVE", isActive: true);
|
||||
var handler = BuildCreateHandler(db, um, new FakeCurrentUser());
|
||||
|
||||
var cmd = BuildCreateCommand(project.Id, workItemId: active.Id);
|
||||
|
||||
var newId = await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
var saved = await db.PurchaseEvaluations.FindAsync(newId);
|
||||
saved.Should().NotBeNull();
|
||||
saved!.WorkItemId.Should().Be(active.Id, "create persist đúng WorkItemId đã chọn");
|
||||
saved.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. UPDATE DRAFT — null-safe partial update (bug-class S42 picker)
|
||||
// ============================================================
|
||||
|
||||
private static UpdatePurchaseEvaluationDraftCommand BuildUpdateCommand(
|
||||
Guid peId, Guid? workItemId)
|
||||
=> new(
|
||||
Id: peId,
|
||||
TenGoiThau: "Gói thầu sửa",
|
||||
DiaDiem: null,
|
||||
MoTa: null,
|
||||
PaymentTerms: null,
|
||||
BudgetId: null,
|
||||
BudgetManualName: null,
|
||||
BudgetManualAmount: null,
|
||||
ApprovalWorkflowId: null,
|
||||
WorkItemId: workItemId);
|
||||
|
||||
private static UpdatePurchaseEvaluationDraftCommandHandler BuildUpdateHandler(
|
||||
TestApplicationDbContext db, ICurrentUser currentUser)
|
||||
=> new(db, currentUser);
|
||||
|
||||
// Phiếu Nháp có sẵn WorkItemId = w1 (state sau Create).
|
||||
private static async Task<PurchaseEvaluation> SeedDraftPeAsync(
|
||||
TestApplicationDbContext db, Guid projectId, Guid workItemId, string code = "PE-WI-UPD")
|
||||
{
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.DangSoanThao,
|
||||
MaPhieu = code,
|
||||
TenGoiThau = "Gói thầu gốc",
|
||||
ProjectId = projectId,
|
||||
WorkItemId = workItemId,
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
};
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return pe;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDraft_WorkItemIdNull_KeepsExistingW1_NotNullified()
|
||||
{
|
||||
// ⭐ Trục QUAN TRỌNG NHẤT (bug-class S42): client cũ / inline-edit KHÔNG gửi
|
||||
// WorkItemId → request.WorkItemId=null → handler GIỮ NGUYÊN w1, KHÔNG null-hoá.
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var project = await SeedProjectAsync(db);
|
||||
var w1 = await SeedWorkItemAsync(db, "WI-1");
|
||||
var pe = await SeedDraftPeAsync(db, project.Id, w1.Id);
|
||||
var handler = BuildUpdateHandler(db, new FakeCurrentUser());
|
||||
|
||||
var cmd = BuildUpdateCommand(pe.Id, workItemId: null);
|
||||
await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.FindAsync(pe.Id);
|
||||
reloaded!.WorkItemId.Should().Be(w1.Id, "null request KHÔNG được null-hoá hạng mục đã chọn");
|
||||
reloaded.TenGoiThau.Should().Be("Gói thầu sửa", "field khác vẫn update bình thường");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDraft_WorkItemIdNewActiveW2_ChangesToW2()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var project = await SeedProjectAsync(db);
|
||||
var w1 = await SeedWorkItemAsync(db, "WI-1");
|
||||
var w2 = await SeedWorkItemAsync(db, "WI-2");
|
||||
var pe = await SeedDraftPeAsync(db, project.Id, w1.Id);
|
||||
var handler = BuildUpdateHandler(db, new FakeCurrentUser());
|
||||
|
||||
var cmd = BuildUpdateCommand(pe.Id, workItemId: w2.Id);
|
||||
await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.FindAsync(pe.Id);
|
||||
reloaded!.WorkItemId.Should().Be(w2.Id, "đổi sang hạng mục W2 active hợp lệ");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDraft_WorkItemIdBogus_ThrowsConflict_AndKeepsW1()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var project = await SeedProjectAsync(db);
|
||||
var w1 = await SeedWorkItemAsync(db, "WI-1");
|
||||
var pe = await SeedDraftPeAsync(db, project.Id, w1.Id);
|
||||
var handler = BuildUpdateHandler(db, new FakeCurrentUser());
|
||||
|
||||
// Đổi sang Guid không tồn tại → guard (wiId != entity.WorkItemId) fail → Conflict.
|
||||
var cmd = BuildUpdateCommand(pe.Id, workItemId: Guid.NewGuid());
|
||||
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*Hạng mục công việc không tồn tại hoặc ngưng hoạt động*");
|
||||
|
||||
// Side-effect assert: throw TRƯỚC SaveChanges → entity row giữ nguyên w1.
|
||||
// Re-read AsNoTracking để tránh đọc instance đã bị mutate trong tracker
|
||||
// (defensive — guard throw trước khi gán nên thực tế chưa mutate, nhưng
|
||||
// AsNoTracking đảm bảo verify DB-truth không phải change-tracker state).
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == pe.Id);
|
||||
reloaded!.WorkItemId.Should().Be(w1.Id, "bogus WorkItemId không được persist, giữ w1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDraft_WorkItemIdInactive_ThrowsConflict()
|
||||
{
|
||||
// WorkItem tồn tại nhưng IsActive=false (vd master bị ngưng sau khi pick) → đổi
|
||||
// sang nó cũng bị guard (w.IsActive) chặn.
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var project = await SeedProjectAsync(db);
|
||||
var w1 = await SeedWorkItemAsync(db, "WI-1");
|
||||
var inactive = await SeedWorkItemAsync(db, "WI-INACTIVE-UPD", isActive: false);
|
||||
var pe = await SeedDraftPeAsync(db, project.Id, w1.Id);
|
||||
var handler = BuildUpdateHandler(db, new FakeCurrentUser());
|
||||
|
||||
var cmd = BuildUpdateCommand(pe.Id, workItemId: inactive.Id);
|
||||
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*Hạng mục công việc không tồn tại hoặc ngưng hoạt động*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDraft_WorkItemIdSameAsExisting_NoGuardLookup_Succeeds()
|
||||
{
|
||||
// request.WorkItemId == entity.WorkItemId (w1) → guard (wiId != entity.WorkItemId)
|
||||
// SKIP lookup → vẫn success kể cả nếu w1 sau đó bị inactive (no re-validate khi
|
||||
// không đổi). Giữ w1.
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var project = await SeedProjectAsync(db);
|
||||
var w1 = await SeedWorkItemAsync(db, "WI-1");
|
||||
var pe = await SeedDraftPeAsync(db, project.Id, w1.Id);
|
||||
var handler = BuildUpdateHandler(db, new FakeCurrentUser());
|
||||
|
||||
var cmd = BuildUpdateCommand(pe.Id, workItemId: w1.Id);
|
||||
await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.FindAsync(pe.Id);
|
||||
reloaded!.WorkItemId.Should().Be(w1.Id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user