[CLAUDE] PurchaseEvaluation: Mig 54 giá đề xuất PRO/CCM + CEO chọn giá chốt + CCM duyệt-done ô-tích
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m22s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m22s
Theo note anh Kiệt FDC (go-live so-sánh-giá thứ Hai): - (1) Giá chào thầu thêm giá đề xuất NGOÀI giá NCC: PRO nhập dải Min/Max + CCM nhập 1 giá (2 lệnh role-gate Procurement/CostControl, fail-closed). Khi duyệt cấp cuối, người duyệt CHỌN 1 giá chốt (Ncc/ProMin/ProMax/Ccm) -> luu ApprovedPriceAmount/Source (bind tai moi nhanh DaDuyet, bat buoc chon; auto-approve he thong mien). - (3) CCM duyet-done mien CEO: DOI tu AUTO-threshold (S69) sang O-TICH-TAY (finalizeByCcmDelegation) -- CCM chu dong tich, fail-closed theo nguong + role + gia goi. An toan hon (khong vo tinh bo CEO). - Mig 54 additive-nullable (5 cot PE) - FE 2 app SHA-mirror - test 306->334 (+28: opt-in 6->11, +10 gia chot, +13 setter authz). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,269 @@
|
||||
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.PurchaseEvaluations;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// ===== NEW (Mig 54 2026-06-18 anh Kiệt FDC) — ③ 2 setter giá-đề-xuất role-gate =====
|
||||
// PeSuggestedPriceFeatures.cs: UpdatePeSuggestedPricePro/CcmCommand(+Handler+Validator).
|
||||
// Test theo CODE đã land (S34 rule — KHÔNG touch production). Mirror harness
|
||||
// PeWorkItemGuardTests (validator plain .Validate() API + handler 2-dep nhẹ
|
||||
// db+ICurrentUser instantiate trực tiếp).
|
||||
//
|
||||
// Authz (mirror UpdatePeBudgetPro/Ccm S61):
|
||||
// - PRO command: role Procurement HOẶC Admin set ProSuggestedMin/Max; role khác →
|
||||
// ForbiddenException (fail-closed TRƯỚC mọi side-effect — guard sau NotFound check).
|
||||
// - CCM command: role CostControl HOẶC Admin set CcmSuggestedPrice; role khác → Forbidden.
|
||||
// - Validator PRO: Min/Max >= 0; cả 2 có → Min <= Max (invalid nếu Min > Max).
|
||||
// - Validator CCM: CcmPrice >= 0.
|
||||
//
|
||||
// ⚠️ Handler check PE-existence (NotFound) TRƯỚC authz gate (PeSuggestedPriceFeatures
|
||||
// line 49-50 rồi 53). Nên unknown-PE → NotFound bất kể role; Forbidden cần PE tồn tại.
|
||||
public class PeSuggestedPriceSetterAuthzTests
|
||||
{
|
||||
private sealed class FakeCurrentUser(params string[] roles) : ICurrentUser
|
||||
{
|
||||
public Guid? UserId { get; } = Guid.NewGuid();
|
||||
public string? Email { get; } = "actor@test.local";
|
||||
public string? FullName { get; } = "Actor Test";
|
||||
public IReadOnlyList<string> Roles { get; } = roles ?? Array.Empty<string>();
|
||||
public bool IsAuthenticated => UserId is not null;
|
||||
}
|
||||
|
||||
private static async Task<PurchaseEvaluation> SeedPeAsync(
|
||||
TestApplicationDbContext db, string code = "PE-SP-001")
|
||||
{
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
MaPhieu = code,
|
||||
TenGoiThau = "Gói thầu test giá đề xuất",
|
||||
ProjectId = Guid.NewGuid(),
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
};
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return pe;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// ===== PRO setter (ProSuggestedMinPrice / ProSuggestedMaxPrice) =====
|
||||
// ====================================================================
|
||||
|
||||
// 1. Procurement set được Min/Max.
|
||||
[Fact]
|
||||
public async Task ProSetter_Procurement_SetsMinMax()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var pe = await SeedPeAsync(db);
|
||||
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));
|
||||
|
||||
await handler.Handle(
|
||||
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 100_000_000m, MaxPrice: 200_000_000m),
|
||||
CancellationToken.None);
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.ProSuggestedMinPrice.Should().Be(100_000_000m);
|
||||
reloaded.ProSuggestedMaxPrice.Should().Be(200_000_000m);
|
||||
}
|
||||
|
||||
// 2. Admin set được Min/Max (allow-list thứ 2).
|
||||
[Fact]
|
||||
public async Task ProSetter_Admin_SetsMinMax()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var pe = await SeedPeAsync(db, "PE-SP-002");
|
||||
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Admin));
|
||||
|
||||
await handler.Handle(
|
||||
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 50_000_000m, MaxPrice: null),
|
||||
CancellationToken.None);
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.ProSuggestedMinPrice.Should().Be(50_000_000m);
|
||||
reloaded.ProSuggestedMaxPrice.Should().BeNull("chỉ 1 trong 2 = giá chốt đó, Max để trống OK");
|
||||
}
|
||||
|
||||
// 3. Role khác (CostControl) → ForbiddenException + KHÔNG set giá (fail-closed).
|
||||
[Fact]
|
||||
public async Task ProSetter_CostControl_ThrowsForbidden_NoSet()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var pe = await SeedPeAsync(db, "PE-SP-003");
|
||||
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
|
||||
|
||||
var act = async () => await handler.Handle(
|
||||
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 1m, MaxPrice: 2m),
|
||||
CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ForbiddenException>()
|
||||
.WithMessage("*PRO*");
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.ProSuggestedMinPrice.Should().BeNull("CCM bị chặn → không set giá PRO");
|
||||
reloaded.ProSuggestedMaxPrice.Should().BeNull();
|
||||
}
|
||||
|
||||
// 3b. Role thường khác (Drafter) → cũng Forbidden (chắc chắn không chỉ CCM bị chặn).
|
||||
[Fact]
|
||||
public async Task ProSetter_Drafter_ThrowsForbidden()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var pe = await SeedPeAsync(db, "PE-SP-003B");
|
||||
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Drafter));
|
||||
|
||||
var act = async () => await handler.Handle(
|
||||
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 1m, MaxPrice: null),
|
||||
CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ForbiddenException>();
|
||||
}
|
||||
|
||||
// 4. Validator PRO: Min > Max → invalid (rule WithMessage "Giá Min phải ≤ Giá Max").
|
||||
[Fact]
|
||||
public void ProValidator_MinGreaterThanMax_Invalid()
|
||||
{
|
||||
var validator = new UpdatePeSuggestedPriceProCommandValidator();
|
||||
|
||||
var result = validator.Validate(
|
||||
new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), MinPrice: 500m, MaxPrice: 100m));
|
||||
|
||||
result.IsValid.Should().BeFalse("Min > Max vi phạm rule");
|
||||
result.Errors.Should().Contain(e => e.ErrorMessage.Contains("Min phải ≤"));
|
||||
}
|
||||
|
||||
// 4b. Validator PRO: Min <= Max → valid. Chỉ 1 trong 2 (Max null) → cũng valid
|
||||
// (rule Min<=Max chỉ When cả 2 HasValue).
|
||||
[Fact]
|
||||
public void ProValidator_MinLeMax_AndSingleValue_Valid()
|
||||
{
|
||||
var validator = new UpdatePeSuggestedPriceProCommandValidator();
|
||||
|
||||
validator.Validate(new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), 100m, 500m))
|
||||
.IsValid.Should().BeTrue("Min <= Max hợp lệ");
|
||||
validator.Validate(new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), 100m, null))
|
||||
.IsValid.Should().BeTrue("chỉ Min (Max null) hợp lệ — không ràng Min<=Max");
|
||||
validator.Validate(new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), null, null))
|
||||
.IsValid.Should().BeTrue("cả 2 null (clear) hợp lệ");
|
||||
}
|
||||
|
||||
// 4c. Validator PRO: giá trị âm → invalid (GreaterThanOrEqualTo(0)).
|
||||
[Fact]
|
||||
public void ProValidator_NegativeValue_Invalid()
|
||||
{
|
||||
var validator = new UpdatePeSuggestedPriceProCommandValidator();
|
||||
|
||||
var result = validator.Validate(
|
||||
new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), MinPrice: -1m, MaxPrice: null));
|
||||
|
||||
result.IsValid.Should().BeFalse("giá âm vi phạm GreaterThanOrEqualTo(0)");
|
||||
}
|
||||
|
||||
// 5. PRO handler unknown PE → NotFound (existence check TRƯỚC authz).
|
||||
[Fact]
|
||||
public async Task ProSetter_UnknownPe_ThrowsNotFound()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));
|
||||
|
||||
var act = async () => await handler.Handle(
|
||||
new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), 1m, 2m), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// ===== CCM setter (CcmSuggestedPrice) =====
|
||||
// ====================================================================
|
||||
|
||||
// 6. CostControl set được CcmSuggestedPrice.
|
||||
[Fact]
|
||||
public async Task CcmSetter_CostControl_SetsPrice()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var pe = await SeedPeAsync(db, "PE-SP-006");
|
||||
var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
|
||||
|
||||
await handler.Handle(
|
||||
new UpdatePeSuggestedPriceCcmCommand(pe.Id, CcmPrice: 333_000_000m), CancellationToken.None);
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.CcmSuggestedPrice.Should().Be(333_000_000m);
|
||||
}
|
||||
|
||||
// 7. Admin set được CcmSuggestedPrice.
|
||||
[Fact]
|
||||
public async Task CcmSetter_Admin_SetsPrice()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var pe = await SeedPeAsync(db, "PE-SP-007");
|
||||
var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.Admin));
|
||||
|
||||
await handler.Handle(
|
||||
new UpdatePeSuggestedPriceCcmCommand(pe.Id, CcmPrice: 1m), CancellationToken.None);
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.CcmSuggestedPrice.Should().Be(1m);
|
||||
}
|
||||
|
||||
// 8. Role khác (Procurement) → ForbiddenException + KHÔNG set giá.
|
||||
[Fact]
|
||||
public async Task CcmSetter_Procurement_ThrowsForbidden_NoSet()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var pe = await SeedPeAsync(db, "PE-SP-008");
|
||||
var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));
|
||||
|
||||
var act = async () => await handler.Handle(
|
||||
new UpdatePeSuggestedPriceCcmCommand(pe.Id, CcmPrice: 9m), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ForbiddenException>()
|
||||
.WithMessage("*CCM*");
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.CcmSuggestedPrice.Should().BeNull("PRO bị chặn → không set giá CCM");
|
||||
}
|
||||
|
||||
// 9. Validator CCM: giá âm → invalid.
|
||||
[Fact]
|
||||
public void CcmValidator_NegativeValue_Invalid()
|
||||
{
|
||||
var validator = new UpdatePeSuggestedPriceCcmCommandValidator();
|
||||
|
||||
validator.Validate(new UpdatePeSuggestedPriceCcmCommand(Guid.NewGuid(), CcmPrice: -5m))
|
||||
.IsValid.Should().BeFalse("giá âm vi phạm GreaterThanOrEqualTo(0)");
|
||||
validator.Validate(new UpdatePeSuggestedPriceCcmCommand(Guid.NewGuid(), CcmPrice: 0m))
|
||||
.IsValid.Should().BeTrue("0 hợp lệ");
|
||||
validator.Validate(new UpdatePeSuggestedPriceCcmCommand(Guid.NewGuid(), CcmPrice: null))
|
||||
.IsValid.Should().BeTrue("null (clear) hợp lệ");
|
||||
}
|
||||
|
||||
// 10. CCM handler unknown PE → NotFound.
|
||||
[Fact]
|
||||
public async Task CcmSetter_UnknownPe_ThrowsNotFound()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
|
||||
|
||||
var act = async () => await handler.Handle(
|
||||
new UpdatePeSuggestedPriceCcmCommand(Guid.NewGuid(), 1m), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user