From b5aa72d0059cd73f9b3e37e870f95089f11a35b4 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Fri, 19 Jun 2026 14:42:08 +0700 Subject: [PATCH] [CLAUDE] PurchaseEvaluation: co gap GAN=NV chuc nang / GO=chi Truong phong (DeptManager) bat doi xung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tra Sol + anh Kiet (Zalo chot 14:25): tranh NV khac lo tay go co cua nguoi da gan. Handler SetPurchaseEvaluationUrgent gate BAT DOI XUNG theo IsUrgent: GAN (true) = role chuc nang (Procurement->do / CostControl->xanh / Admin->ca 2); GO (false) = role chuc nang + DeptManager (Truong phong) hoac Admin. FE PeDetailTabs nut toggle gate theo trang thai hien tai (da gap->can quyen GO; chua gap->can quyen GAN) → an nut GO voi NV thuong. Test PeUrgentToggleAuthzTests rewrite asymmetric (354 PASS 0 fail). FE 2 app SHA256-identical. Build slnx 0-err. Co-Authored-By: Claude Opus 4.8 --- fe-admin/src/components/pe/PeDetailTabs.tsx | 12 +- fe-user/src/components/pe/PeDetailTabs.tsx | 12 +- .../PurchaseEvaluationUrgentFeatures.cs | 16 +- .../Application/PeUrgentToggleAuthzTests.cs | 227 ++++++++++++------ 4 files changed, 188 insertions(+), 79 deletions(-) diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index 34b627f..8ff1a6e 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/src/components/pe/PeDetailTabs.tsx @@ -126,8 +126,16 @@ export function PeDetailTabs({ // BE chặn Forbidden role khác → FE chỉ ẩn nút (UX), không phải security. const isPro = currentUser?.roles?.includes('Procurement') ?? false const isCcm = currentUser?.roles?.includes('CostControl') ?? false - const canToggleProUrgent = isAdmin || isPro - const canToggleCcmUrgent = isAdmin || isCcm + // [S77 Tra Sol/anh Kiệt — chốt] BẤT ĐỐI XỨNG: GẮN = NV chức năng (ai làm nấy gắn); + // GỠ = chỉ Trưởng phòng (DeptManager)/Admin (tránh NV khác lỡ tay gỡ). Nút phụ thuộc + // trạng thái hiện tại: đã gấp → cần quyền GỠ; chưa gấp → cần quyền GẮN. + const isDeptManager = currentUser?.roles?.includes('DeptManager') ?? false + const canToggleProUrgent = evaluation.isUrgentByPro + ? (isAdmin || (isPro && isDeptManager)) + : (isAdmin || isPro) + const canToggleCcmUrgent = evaluation.isUrgentByCcm + ? (isAdmin || (isCcm && isDeptManager)) + : (isAdmin || isCcm) const v2Approvers = evaluation.currentApproval?.approvers ?? [] const actorMatchesLevel = isAdmin || (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id)) diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx index 34b627f..8ff1a6e 100644 --- a/fe-user/src/components/pe/PeDetailTabs.tsx +++ b/fe-user/src/components/pe/PeDetailTabs.tsx @@ -126,8 +126,16 @@ export function PeDetailTabs({ // BE chặn Forbidden role khác → FE chỉ ẩn nút (UX), không phải security. const isPro = currentUser?.roles?.includes('Procurement') ?? false const isCcm = currentUser?.roles?.includes('CostControl') ?? false - const canToggleProUrgent = isAdmin || isPro - const canToggleCcmUrgent = isAdmin || isCcm + // [S77 Tra Sol/anh Kiệt — chốt] BẤT ĐỐI XỨNG: GẮN = NV chức năng (ai làm nấy gắn); + // GỠ = chỉ Trưởng phòng (DeptManager)/Admin (tránh NV khác lỡ tay gỡ). Nút phụ thuộc + // trạng thái hiện tại: đã gấp → cần quyền GỠ; chưa gấp → cần quyền GẮN. + const isDeptManager = currentUser?.roles?.includes('DeptManager') ?? false + const canToggleProUrgent = evaluation.isUrgentByPro + ? (isAdmin || (isPro && isDeptManager)) + : (isAdmin || isPro) + const canToggleCcmUrgent = evaluation.isUrgentByCcm + ? (isAdmin || (isCcm && isDeptManager)) + : (isAdmin || isCcm) const v2Approvers = evaluation.currentApproval?.approvers ?? [] const actorMatchesLevel = isAdmin || (currentUser?.id != null && v2Approvers.some(a => a.userId === currentUser.id)) diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationUrgentFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationUrgentFeatures.cs index cb55aea..fbac485 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationUrgentFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationUrgentFeatures.cs @@ -38,11 +38,21 @@ public class SetPurchaseEvaluationUrgentCommandHandler( var roles = currentUser.Roles; var isAdmin = roles.Contains(AppRoles.Admin); - var isPro = roles.Contains(AppRoles.Procurement); - var isCcm = roles.Contains(AppRoles.CostControl); + var isDeptManager = roles.Contains(AppRoles.DeptManager); + var hasPro = roles.Contains(AppRoles.Procurement); + var hasCcm = roles.Contains(AppRoles.CostControl); + + // [S77 Tra Sol/anh Kiệt — chốt 14:25] BẤT ĐỐI XỨNG theo IsUrgent: + // • GẮN (true): "ai làm người đó gắn" → NV chức năng PRO/CCM tự đánh dấu (chỉ cần role chức năng). + // • GỠ (false): "gỡ thì chỉ cho TP gỡ" → CHỈ Trưởng phòng (DeptManager) của phòng đó hoặc Admin + // (tránh NV khác lỡ tay gỡ cờ của người đã gắn). + var isPro = request.IsUrgent ? hasPro : (hasPro && isDeptManager); + var isCcm = request.IsUrgent ? hasCcm : (hasCcm && isDeptManager); if (!isAdmin && !isPro && !isCcm) - throw new ForbiddenException("Chỉ PRO (Procurement) / CCM (CostControl) / Admin được đánh dấu phiếu gấp."); + throw new ForbiddenException(request.IsUrgent + ? "Chỉ PRO (Cung ứng) / CCM (Kiểm soát chi phí) / Admin được đánh dấu phiếu gấp." + : "Chỉ Trưởng phòng Cung ứng / Kiểm soát chi phí hoặc Admin được GỠ cờ gấp."); // Snapshot để phát hiện chuyển false→true (MỚI bật gấp) → notify CEO 1 lần. var wasUrgent = entity.IsUrgentByPro || entity.IsUrgentByCcm; diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PeUrgentToggleAuthzTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PeUrgentToggleAuthzTests.cs index 2a37bc2..8994f8a 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/PeUrgentToggleAuthzTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/PeUrgentToggleAuthzTests.cs @@ -15,16 +15,31 @@ namespace SolutionErp.Infrastructure.Tests.Application; // SetPurchaseEvaluationUrgentCommandHandler (PurchaseEvaluationUrgentFeatures.cs). // Test theo CODE đã land (S34 rule — KHÔNG touch production). // -// Logic role → cờ: -// - Procurement (PRO) → set IsUrgentByPro (IsUrgentByCcm KHÔNG đụng) -// - CostControl (CCM) → set IsUrgentByCcm (IsUrgentByPro KHÔNG đụng) -// - Admin → set CẢ 2 cờ -// - role khác (Drafter/Finance/...) → ForbiddenException (không lưu gì) +// [SPEC CHANGE — FINAL anh chốt, 2026-06-19] Gate BẤT ĐỐI XỨNG theo request.IsUrgent +// (KHÁC bản S77 trước đó vốn ĐỐI XỨNG yêu cầu DeptManager cho cả set + unset): +// +// SET (IsUrgent=true) — "ai làm người đó gắn": CHỈ cần role chức năng. +// • Procurement (KHÔNG cần DeptManager) → set IsUrgentByPro (cờ ĐỎ) +// • CostControl (KHÔNG cần DeptManager) → set IsUrgentByCcm (cờ XANH) +// • Admin → set CẢ 2 cờ +// • role khác → ForbiddenException ("*đánh dấu phiếu gấp*") +// +// UNSET (IsUrgent=false) — "gỡ thì chỉ cho TP gỡ": role chức năng VÀ DeptManager. +// • Procurement + DeptManager → clear IsUrgentByPro +// • CostControl + DeptManager → clear IsUrgentByCcm +// • Admin → clear CẢ 2 (KHÔNG cần DeptManager) +// • plain Procurement (no DeptManager) → ForbiddenException + NO mutation +// • plain CostControl (no DeptManager) → ForbiddenException + NO mutation +// • role khác → ForbiddenException ("*GỠ cờ gấp*") +// +// Production gate (PurchaseEvaluationUrgentFeatures.cs:49-55): +// isPro = IsUrgent ? hasPro : (hasPro && isDeptManager); +// isCcm = IsUrgent ? hasCcm : (hasCcm && isDeptManager); +// if (!isAdmin && !isPro && !isCcm) throw Forbidden(); // // Notify CEO (Director) là best-effort try/catch khi false→true → KHÔNG assert // notification ở đây (focus = flag-setting + authz). NoOpNotificationService nuốt -// call → try block không throw. UserManager lấy từ IdentityFixture (GetUsersInRoleAsync -// trả rỗng khi không seed Director — best-effort no-op, đúng visibility-only Q3). +// call → try block không throw. UserManager lấy từ IdentityFixture. // // Handler 4 dep: (IApplicationDbContext, ICurrentUser, UserManager, INotificationService). // FakeCurrentUser configurable Roles per scenario. @@ -70,10 +85,11 @@ public class PeUrgentToggleAuthzTests } // ===================================================================== - // 1. Procurement → set CHỈ IsUrgentByPro=true, IsUrgentByCcm KHÔNG đụng. + // 1. SET — plain Procurement (KHÔNG DeptManager) → OK, set CHỈ IsUrgentByPro=true, + // ByCcm KHÔNG đụng. "Ai làm người đó gắn" — không cần Trưởng phòng để bật. // ===================================================================== [Fact] - public async Task Procurement_SetsOnlyIsUrgentByPro_CcmUntouched() + public async Task PlainProcurement_Set_SetsOnlyIsUrgentByPro_CcmUntouched() { using var fix = new IdentityFixture(); var db = fix.Services.GetRequiredService(); @@ -84,15 +100,16 @@ public class PeUrgentToggleAuthzTests await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None); var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); - reloaded.IsUrgentByPro.Should().BeTrue("PRO set cờ ĐỎ (ByPro)"); + reloaded.IsUrgentByPro.Should().BeTrue("PRO set cờ ĐỎ (ByPro) khi bật — chỉ cần role Procurement"); reloaded.IsUrgentByCcm.Should().BeFalse("PRO KHÔNG đụng cờ XANH (ByCcm)"); } // ===================================================================== - // 2. CostControl → set CHỈ IsUrgentByCcm=true, IsUrgentByPro KHÔNG đụng. + // 2. SET — plain CostControl (KHÔNG DeptManager) → OK, set CHỈ IsUrgentByCcm=true, + // ByPro KHÔNG đụng. Đối xứng test 1 cho phía CCM. // ===================================================================== [Fact] - public async Task CostControl_SetsOnlyIsUrgentByCcm_ProUntouched() + public async Task PlainCostControl_Set_SetsOnlyIsUrgentByCcm_ProUntouched() { using var fix = new IdentityFixture(); var db = fix.Services.GetRequiredService(); @@ -103,15 +120,15 @@ public class PeUrgentToggleAuthzTests await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None); var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); - reloaded.IsUrgentByCcm.Should().BeTrue("CCM set cờ XANH (ByCcm)"); + reloaded.IsUrgentByCcm.Should().BeTrue("CCM set cờ XANH (ByCcm) khi bật — chỉ cần role CostControl"); reloaded.IsUrgentByPro.Should().BeFalse("CCM KHÔNG đụng cờ ĐỎ (ByPro)"); } // ===================================================================== - // 3. Admin → set CẢ 2 cờ true. + // 3. SET — Admin → set CẢ 2 cờ true (KHÔNG cần DeptManager — superuser). // ===================================================================== [Fact] - public async Task Admin_SetsBothFlags() + public async Task Admin_Set_SetsBothFlags() { using var fix = new IdentityFixture(); var db = fix.Services.GetRequiredService(); @@ -127,22 +144,133 @@ public class PeUrgentToggleAuthzTests } // ===================================================================== - // 4. Role không có quyền (Drafter) → ForbiddenException, KHÔNG lưu gì. + // 4. UNSET — plain Procurement (KHÔNG DeptManager) → ForbiddenException + NO mutation. + // "Gỡ thì chỉ cho TP gỡ": NV PRO thường KHÔNG được gỡ cờ → cờ ĐỎ giữ true. // ===================================================================== [Fact] - public async Task Drafter_ThrowsForbidden_NoFlagSet() + public async Task PlainProcurement_Unset_ThrowsForbidden_FlagStillTrue() { using var fix = new IdentityFixture(); var db = fix.Services.GetRequiredService(); var um = fix.Services.GetRequiredService>(); - var pe = await SeedPeAsync(db, code: "PE-URG-004"); + // Pre-seed cờ ĐỎ đang bật để chứng minh GỠ bị chặn KHÔNG mutate. + var pe = await SeedPeAsync(db, urgentByPro: true, code: "PE-URG-004"); + var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement)); + + var act = async () => await handler.Handle( + new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*GỠ cờ gấp*"); + + // Guard throw TRƯỚC mutate → cờ ĐỎ giữ nguyên true (NV thường không gỡ được). + var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reloaded.IsUrgentByPro.Should().BeTrue("PRO thường bị chặn gỡ → cờ ĐỎ giữ nguyên"); + reloaded.IsUrgentByCcm.Should().BeFalse(); + } + + // ===================================================================== + // 5. UNSET — plain CostControl (KHÔNG DeptManager) → ForbiddenException + NO mutation. + // Đối xứng test 4 cho phía CCM (cờ XANH giữ true). + // ===================================================================== + [Fact] + public async Task PlainCostControl_Unset_ThrowsForbidden_FlagStillTrue() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + var pe = await SeedPeAsync(db, urgentByCcm: true, code: "PE-URG-005"); + var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.CostControl)); + + var act = async () => await handler.Handle( + new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*GỠ cờ gấp*"); + + var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reloaded.IsUrgentByCcm.Should().BeTrue("CCM thường bị chặn gỡ → cờ XANH giữ nguyên"); + reloaded.IsUrgentByPro.Should().BeFalse(); + } + + // ===================================================================== + // 6. UNSET — Procurement + DeptManager → OK, clear IsUrgentByPro=false. + // Trưởng phòng PRO ĐƯỢC gỡ cờ ĐỎ. ByCcm KHÔNG đụng (vd CCM bật trước đó vẫn giữ). + // ===================================================================== + [Fact] + public async Task ProcurementDeptManager_Unset_ClearsOnlyProFlag_CcmPreserved() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + // State: cả 2 cờ đang bật (PRO ĐỎ + CCM XANH). + var pe = await SeedPeAsync(db, urgentByPro: true, urgentByCcm: true, code: "PE-URG-006"); + var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement, AppRoles.DeptManager)); + + await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None); + + var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reloaded.IsUrgentByPro.Should().BeFalse("Trưởng phòng PRO gỡ cờ ĐỎ của mình"); + reloaded.IsUrgentByCcm.Should().BeTrue("cờ XANH của CCM giữ nguyên — PRO không đụng"); + } + + // ===================================================================== + // 7. UNSET — CostControl + DeptManager → OK, clear IsUrgentByCcm=false. + // Đối xứng test 6 cho phía CCM. ByPro giữ nguyên. + // ===================================================================== + [Fact] + public async Task CostControlDeptManager_Unset_ClearsOnlyCcmFlag_ProPreserved() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + var pe = await SeedPeAsync(db, urgentByPro: true, urgentByCcm: true, code: "PE-URG-007"); + var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.CostControl, AppRoles.DeptManager)); + + await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None); + + var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reloaded.IsUrgentByCcm.Should().BeFalse("Trưởng phòng CCM gỡ cờ XANH của mình"); + reloaded.IsUrgentByPro.Should().BeTrue("cờ ĐỎ của PRO giữ nguyên — CCM không đụng"); + } + + // ===================================================================== + // 8. UNSET — Admin → clear CẢ 2 cờ (KHÔNG cần DeptManager — superuser). + // ===================================================================== + [Fact] + public async Task Admin_Unset_ClearsBothFlags() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + var pe = await SeedPeAsync(db, urgentByPro: true, urgentByCcm: true, code: "PE-URG-008"); + var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Admin)); + + await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None); + + var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reloaded.IsUrgentByPro.Should().BeFalse("Admin gỡ cả ĐỎ"); + reloaded.IsUrgentByCcm.Should().BeFalse("Admin gỡ cả XANH"); + } + + // ===================================================================== + // 9a. SET — role ngoài allow-list (Drafter) → ForbiddenException, KHÔNG lưu gì. + // Message nhánh SET ("*đánh dấu phiếu gấp*"). + // ===================================================================== + [Fact] + public async Task Drafter_Set_ThrowsForbidden_NoFlagSet() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + var pe = await SeedPeAsync(db, code: "PE-URG-009A"); var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Drafter)); var act = async () => await handler.Handle( new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None); await act.Should().ThrowAsync() - .WithMessage("*PRO*CCM*Admin*"); + .WithMessage("*đánh dấu phiếu gấp*"); // Side-effect: guard throw TRƯỚC mutate → cả 2 cờ giữ false. var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); @@ -151,73 +279,28 @@ public class PeUrgentToggleAuthzTests } // ===================================================================== - // 4b. Role Finance (cũng ngoài allow-list) → ForbiddenException. Cover thêm 1 - // role thường để chắc chắn KHÔNG chỉ Drafter bị chặn. + // 9b. SET — Finance (role thường khác) → ForbiddenException. Chắc chắn KHÔNG chỉ + // Drafter bị chặn — mọi role ngoài {Procurement, CostControl, Admin} đều bị chặn. // ===================================================================== [Fact] - public async Task Finance_ThrowsForbidden() + public async Task Finance_Set_ThrowsForbidden() { using var fix = new IdentityFixture(); var db = fix.Services.GetRequiredService(); var um = fix.Services.GetRequiredService>(); - var pe = await SeedPeAsync(db, code: "PE-URG-004B"); + var pe = await SeedPeAsync(db, code: "PE-URG-009B"); var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Finance)); var act = async () => await handler.Handle( new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync() + .WithMessage("*đánh dấu phiếu gấp*"); } // ===================================================================== - // 5. IsUrgent=false (tắt cờ) — PRO tắt chỉ ByPro, ByCcm giữ nguyên (vd CCM đã - // bật trước đó). Verify partial-clear theo role + KHÔNG đụng cờ role khác. - // ===================================================================== - [Fact] - public async Task Procurement_TurnOff_ClearsOnlyProFlag_CcmFlagPreserved() - { - using var fix = new IdentityFixture(); - var db = fix.Services.GetRequiredService(); - var um = fix.Services.GetRequiredService>(); - // State: cả 2 cờ đang bật (PRO đã bật ĐỎ, CCM đã bật XANH). - var pe = await SeedPeAsync(db, urgentByPro: true, urgentByCcm: true, code: "PE-URG-005"); - var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement)); - - await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None); - - var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); - reloaded.IsUrgentByPro.Should().BeFalse("PRO tắt cờ ĐỎ của mình"); - reloaded.IsUrgentByCcm.Should().BeTrue("cờ XANH của CCM giữ nguyên — PRO không đụng"); - } - - // ===================================================================== - // 6. Multi-role actor có CẢ Procurement (không Admin) — vẫn chỉ là PRO-path - // (Admin > PRO > CCM trong if-else). Đây actor PRO → set ByPro only. - // (Edge: nếu sau này role priority đổi → test này đỏ → review chủ đích.) - // ===================================================================== - [Fact] - public async Task ActorWithBothProAndCcmRoles_NoAdmin_SetsBothFlagsViaElseIfChain_LocksBehavior() - { - using var fix = new IdentityFixture(); - var db = fix.Services.GetRequiredService(); - var um = fix.Services.GetRequiredService>(); - var pe = await SeedPeAsync(db, code: "PE-URG-006"); - // Actor mang CẢ Procurement + CostControl (KHÔNG Admin). - var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement, AppRoles.CostControl)); - - await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None); - - // Code path (line 51-63): isAdmin=false → else-if isPro=true → CHỈ set ByPro - // (else-if chain ngắn mạch, KHÔNG vào nhánh isCcm). LOCK behavior hiện tại: - // PRO ưu tiên hơn CCM khi user kiêm 2 role nhưng không Admin → chỉ ByPro. - var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); - reloaded.IsUrgentByPro.Should().BeTrue("PRO branch chạy (else-if đầu khớp)"); - reloaded.IsUrgentByCcm.Should().BeFalse( - "else-if ngắn mạch — actor kiêm PRO+CCM (no Admin) chỉ set ByPro, KHÔNG vào nhánh CCM"); - } - - // ===================================================================== - // 7. PE không tồn tại → NotFoundException (guard đầu handler trước authz). + // 9c. PE không tồn tại → NotFoundException (existence check đầu handler, TRƯỚC authz). + // Dùng plain Procurement để chắc NotFound bắn trước cả khi role hợp lệ. // ===================================================================== [Fact] public async Task UnknownPe_ThrowsNotFound()