[CLAUDE] PurchaseEvaluation Tests: Chunk O1-O5 — HOTFIX 4 lookup sites cùng pattern per-NV (Plan N point 9 cascade)
Bro UAT 2026-05-15 sau Plan N deploy phát hiện 2 bug mới:
1. Actor NV Test trong OR-of-N slot click "Trả lại Người chỉ định" → toast
"Không phải lượt bạn — chỉ NV Cấp duyệt hiện tại mới được Trả lại / Từ
chối phiếu" mặc dù NV Test đúng trong slot.
2. F2 Duyệt thẳng Cấp cuối → trỏ đến Phan Văn Chương Bước 2 Cấp 2 thay vì
Nguyễn Văn Trường Bước 3 Cấp 1 (BOD) — defer follow-up vì F2 logic line
483-524 đã đúng (lastStepIdx + lastLevelMaxOrder), cần verify workflow
v14 DB structure.
Audit em main: Plan N chỉ fix 1/5 lookup sites — còn 4 sites cùng bug pattern:
1. Service.cs:201 EnsureCanRejectV2Async — bug bro UAT 1 ROOT CAUSE
2. Service.cs:248 ApplyReturnModeAsync — read Allow flag từ row đầu
3. DetailFeatures.cs:72 F3 EnsureEditableForDetailsAsync — cùng bug
4. Features.cs:311 F4 AdjustBudgetCommand — cùng bug
4 fix surgical (~30 LOC BE total):
**Site 1** (`PurchaseEvaluationWorkflowService.cs:201`):
```diff
- var currentLevel = step.Levels.FirstOrDefault(l => l.Order == curLvl);
- if (currentLevel?.ApproverUserId != actorId)
+ var currentLevel = step.Levels.FirstOrDefault(l =>
+ l.Order == curLvl && l.ApproverUserId == actorId);
+ if (currentLevel is null)
throw new ForbiddenException("Không phải lượt bạn — ...");
```
**Site 2** (`PurchaseEvaluationWorkflowService.cs:248`): ApplyReturnModeAsync
+`Guid? actorUserId` param 4th + caller TransitionAsync:94 update. Filter
`l.ApproverUserId == actorUserId` trong FirstOrDefault. Non-admin actor
KHÔNG match slot → currentLevel=null → validation skip (mode logic switch
KHÔNG dùng currentLevel object — chỉ dùng curStepIdx + curLevel int values).
Admin bypass validation existing line 252.
**Site 3** (`PurchaseEvaluationDetailFeatures.cs:72`):
```diff
- var level = step?.Levels.FirstOrDefault(lv => lv.Order == levelOrder);
- if (level is null) throw ConflictException("schema lỗi");
- if (!level.AllowApproverEditDetails) throw ConflictException(...);
- if (level.ApproverUserId != actorUserId) throw ForbiddenException(...);
+ var level = step?.Levels.FirstOrDefault(lv =>
+ lv.Order == levelOrder && lv.ApproverUserId == actorUserId);
+ if (level is null) throw ForbiddenException(...);
+ if (!level.AllowApproverEditDetails) throw ConflictException(...);
```
**Site 4** (`PurchaseEvaluationFeatures.cs:311`):
```diff
- var level = step.Levels.FirstOrDefault(l => l.Order == curLvl);
- if (level is null) throw ConflictException("schema lỗi");
- if (!level.AllowApproverEditBudget) throw ConflictException(...);
- if (level.ApproverUserId != actorId) throw ForbiddenException(...);
+ var level = step.Levels.FirstOrDefault(l =>
+ l.Order == curLvl && l.ApproverUserId == actorId);
+ if (level is null) throw ForbiddenException(...);
+ if (!level.AllowApproverEditBudget) throw ConflictException(...);
```
**Regression test** (`PurchaseEvaluationPerNvLookupRegressionTests.cs` 3 test):
1. `TransitionReject_ActorD_LastInSlot_AllowsRejectViaDrafterMode` —
Actor D (non-first-row trong OR-of-N) trả lại Drafter mode → no throw.
Pre-fix: throw "Không phải lượt bạn" vì handler check row đầu A.
2. `TransitionReject_Outsider_NotInSlot_ThrowsForbidden` — Outsider không
trong slot → throw đúng intent (verify fix KHÔNG over-permissive).
3. `TransitionRejectOneLevel_ActorC_HasFlagWhileOthersDont_AllowsMode` —
Actor C only tick AllowReturnOneLevel, 3 NV khác KHÔNG. Actor C click
"Trả lại 1 Cấp" → mode allowed. Pre-fix: read flag từ row A (false) →
throw ConflictException "không bật mode OneLevel".
Pattern reinforced: per-NV admin opt-in flag wire **5 lookup sites** đều
phải discriminate ApproverUserId. Plan N chỉ catch 1/5. Plan O catch 4/5
còn lại. Memory user-level cần update danh sách 5 sites cho future audit.
Verify:
- dotnet build SolutionErp.slnx clean (0 err, 2 warn pre-existing DocxRenderer)
- dotnet test SolutionErp.slnx **111/111 PASS** (+3 từ 108 baseline Plan N)
Pending Chunk O7: docs + memory update commit + push.
Pending Chunk O8: CICD Monitor post-deploy verify.
Pending follow-up Bug 2 F2 đến Phan Văn Chương: verify workflow v14 DB.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -69,23 +69,24 @@ internal static class PurchaseEvaluationDraftGuard
|
|||||||
?? throw new ConflictException("Workflow không tồn tại.");
|
?? throw new ConflictException("Workflow không tồn tại.");
|
||||||
|
|
||||||
var step = workflow.Steps.OrderBy(s => s.Order).Skip(stepIdx).FirstOrDefault();
|
var step = workflow.Steps.OrderBy(s => s.Order).Skip(stepIdx).FirstOrDefault();
|
||||||
var level = step?.Levels.FirstOrDefault(lv => lv.Order == levelOrder);
|
// Plan O S23 t5 — Per-NV lookup site MUST discriminate ApproverUserId.
|
||||||
|
// Schema Mig 29 OR-of-N: N rows cùng Order. Filter actor match
|
||||||
|
// ngay trong FirstOrDefault tránh chọn nhầm row đầu DB.
|
||||||
|
var level = step?.Levels.FirstOrDefault(lv =>
|
||||||
|
lv.Order == levelOrder && lv.ApproverUserId == actorUserId);
|
||||||
if (level is null)
|
if (level is null)
|
||||||
throw new ConflictException("Workflow Bước/Cấp không tìm thấy — schema lỗi.");
|
throw new ForbiddenException(
|
||||||
|
$"Chỉ NV phụ trách Bước {step?.Order} / Cấp {levelOrder} " +
|
||||||
|
"mới được chỉnh sửa Section 2 lúc đang duyệt.");
|
||||||
|
|
||||||
// Mig 29 (S21 t5) — F3 flag move xuống Level slot (per-NV). Đọc
|
// Mig 29 (S21 t5) — F3 flag move xuống Level slot (per-NV). Đọc
|
||||||
// từ level.AllowApproverEditDetails thay vì workflow-level cũ.
|
// từ level.AllowApproverEditDetails của chính actor.
|
||||||
if (!level.AllowApproverEditDetails)
|
if (!level.AllowApproverEditDetails)
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
$"Cấp Approver hiện tại (Bước {step!.Order} / Cấp {levelOrder}) " +
|
$"Cấp Approver hiện tại (Bước {step!.Order} / Cấp {levelOrder}) " +
|
||||||
"không được cấp quyền chỉnh sửa Section 2. " +
|
"không được cấp quyền chỉnh sửa Section 2. " +
|
||||||
"Phải Trả lại Drafter sửa hoặc liên hệ Admin Designer cấp quyền slot.");
|
"Phải Trả lại Drafter sửa hoặc liên hệ Admin Designer cấp quyền slot.");
|
||||||
|
|
||||||
if (level.ApproverUserId != actorUserId)
|
|
||||||
throw new ForbiddenException(
|
|
||||||
$"Chỉ NV phụ trách Bước {step!.Order} / Cấp {levelOrder} " +
|
|
||||||
"mới được chỉnh sửa Section 2 lúc đang duyệt.");
|
|
||||||
|
|
||||||
return pe;
|
return pe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -308,17 +308,19 @@ public class AdjustPurchaseEvaluationBudgetCommandHandler(
|
|||||||
if (csi < 0 || csi >= stepsOrdered.Count)
|
if (csi < 0 || csi >= stepsOrdered.Count)
|
||||||
throw new ConflictException("Pointer step out of range — schema lỗi.");
|
throw new ConflictException("Pointer step out of range — schema lỗi.");
|
||||||
var step = stepsOrdered[csi];
|
var step = stepsOrdered[csi];
|
||||||
var level = step.Levels.FirstOrDefault(l => l.Order == curLvl);
|
// Plan O S23 t5 — Per-NV lookup site MUST discriminate ApproverUserId.
|
||||||
|
// Schema Mig 29 OR-of-N: N rows cùng Order. Filter actor match
|
||||||
|
// ngay trong FirstOrDefault tránh chọn nhầm row đầu DB.
|
||||||
|
var level = step.Levels.FirstOrDefault(l =>
|
||||||
|
l.Order == curLvl && l.ApproverUserId == actorId);
|
||||||
if (level is null)
|
if (level is null)
|
||||||
throw new ConflictException("Cấp duyệt không tìm thấy — schema lỗi.");
|
throw new ForbiddenException(
|
||||||
|
$"Chỉ NV phụ trách Bước {step.Order} / Cấp {curLvl} mới được điều chỉnh ngân sách lúc đang duyệt.");
|
||||||
if (!level.AllowApproverEditBudget)
|
if (!level.AllowApproverEditBudget)
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
$"Cấp Approver hiện tại (Bước {step.Order} / Cấp {curLvl}) " +
|
$"Cấp Approver hiện tại (Bước {step.Order} / Cấp {curLvl}) " +
|
||||||
"không được cấp quyền chỉnh sửa Section ngân sách. " +
|
"không được cấp quyền chỉnh sửa Section ngân sách. " +
|
||||||
"Liên hệ Admin Designer cấp quyền slot.");
|
"Liên hệ Admin Designer cấp quyền slot.");
|
||||||
if (level.ApproverUserId != actorId)
|
|
||||||
throw new ForbiddenException(
|
|
||||||
$"Chỉ NV phụ trách Bước {step.Order} / Cấp {curLvl} mới được điều chỉnh ngân sách lúc đang duyệt.");
|
|
||||||
actorTag = $"[Approver Bước {step.Order}/Cấp {curLvl}]";
|
actorTag = $"[Approver Bước {step.Order}/Cấp {curLvl}]";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@ -92,7 +92,7 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
// Default fallback (returnMode=null) = Drafter mode = S17 behavior.
|
// Default fallback (returnMode=null) = Drafter mode = S17 behavior.
|
||||||
var effectiveMode = returnMode ?? WorkflowReturnMode.Drafter;
|
var effectiveMode = returnMode ?? WorkflowReturnMode.Drafter;
|
||||||
var returnSummary = await ApplyReturnModeAsync(
|
var returnSummary = await ApplyReturnModeAsync(
|
||||||
evaluation, effectiveMode, returnTargetUserId, isAdmin, ct);
|
evaluation, effectiveMode, returnTargetUserId, actorUserId, isAdmin, ct);
|
||||||
comment = string.IsNullOrWhiteSpace(comment)
|
comment = string.IsNullOrWhiteSpace(comment)
|
||||||
? returnSummary
|
? returnSummary
|
||||||
: $"{comment} [{returnSummary}]";
|
: $"{comment} [{returnSummary}]";
|
||||||
@ -198,8 +198,11 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
var stepsOrdered = workflow.Steps.OrderBy(s => s.Order).ToList();
|
var stepsOrdered = workflow.Steps.OrderBy(s => s.Order).ToList();
|
||||||
if (csi < 0 || csi >= stepsOrdered.Count) return; // pointer corrupt
|
if (csi < 0 || csi >= stepsOrdered.Count) return; // pointer corrupt
|
||||||
var step = stepsOrdered[csi];
|
var step = stepsOrdered[csi];
|
||||||
var currentLevel = step.Levels.FirstOrDefault(l => l.Order == curLvl);
|
// Plan O S23 t5 — Per-NV lookup site MUST discriminate ApproverUserId.
|
||||||
if (currentLevel?.ApproverUserId != actorId)
|
// Schema Mig 29 OR-of-N: N rows cùng Order, mỗi row 1 NV. Filter ngay
|
||||||
|
// trong FirstOrDefault thay vì compare sau (tránh chọn nhầm row đầu DB).
|
||||||
|
var currentLevel = step.Levels.FirstOrDefault(l => l.Order == curLvl && l.ApproverUserId == actorId);
|
||||||
|
if (currentLevel is null)
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"Không phải lượt bạn — chỉ NV Cấp duyệt hiện tại mới được Trả lại / Từ chối phiếu.");
|
"Không phải lượt bạn — chỉ NV Cấp duyệt hiện tại mới được Trả lại / Từ chối phiếu.");
|
||||||
}
|
}
|
||||||
@ -213,6 +216,7 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
PurchaseEvaluation evaluation,
|
PurchaseEvaluation evaluation,
|
||||||
WorkflowReturnMode mode,
|
WorkflowReturnMode mode,
|
||||||
Guid? returnTargetUserId,
|
Guid? returnTargetUserId,
|
||||||
|
Guid? actorUserId,
|
||||||
bool isAdmin,
|
bool isAdmin,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
@ -241,11 +245,19 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
// Resolve Level hiện tại (slot Approver đang duyệt) — đọc Allow* từ slot
|
// Resolve Level hiện tại (slot Approver đang duyệt) — đọc Allow* từ slot
|
||||||
// này. Required cho mọi mode (kể cả Drafter — Approver hiện tại quyết
|
// này. Required cho mọi mode (kể cả Drafter — Approver hiện tại quyết
|
||||||
// định mode Trả lại theo flag riêng của slot).
|
// định mode Trả lại theo flag riêng của slot).
|
||||||
|
//
|
||||||
|
// Plan O S23 t5 — Per-NV lookup site MUST discriminate ApproverUserId.
|
||||||
|
// Schema Mig 29 OR-of-N: N rows cùng Order. Non-admin actor BẮT BUỘC
|
||||||
|
// match slot để đọc Allow flag riêng. Admin bypass validation line 252
|
||||||
|
// bất kể currentLevel null (mode logic switch phía dưới không dùng
|
||||||
|
// currentLevel object — chỉ dùng curStepIdx + curLevel int values).
|
||||||
ApprovalWorkflowLevel? currentLevel = null;
|
ApprovalWorkflowLevel? currentLevel = null;
|
||||||
if (evaluation.CurrentWorkflowStepIndex is int csi && csi >= 0 && csi < stepsOrdered.Count)
|
if (evaluation.CurrentWorkflowStepIndex is int csi && csi >= 0 && csi < stepsOrdered.Count)
|
||||||
{
|
{
|
||||||
var step = stepsOrdered[csi];
|
var step = stepsOrdered[csi];
|
||||||
currentLevel = step.Levels.FirstOrDefault(l => l.Order == evaluation.CurrentApprovalLevelOrder);
|
currentLevel = step.Levels.FirstOrDefault(l =>
|
||||||
|
l.Order == evaluation.CurrentApprovalLevelOrder
|
||||||
|
&& l.ApproverUserId == actorUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate Allow* flag từ Level slot hiện tại (Admin bypass)
|
// Validate Allow* flag từ Level slot hiện tại (Admin bypass)
|
||||||
|
|||||||
@ -0,0 +1,227 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.PurchaseEvaluations.Services;
|
||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.Contracts; // ApprovalDecision
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
using SolutionErp.Infrastructure.Services;
|
||||||
|
using SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Services;
|
||||||
|
|
||||||
|
// Plan O S23 t5 (2026-05-15) — Regression test cho 4 BE lookup sites cùng
|
||||||
|
// bug pattern Plan N: Schema Mig 29 OR-of-N (N rows cùng Order) — handler
|
||||||
|
// FirstOrDefault(Order==X) không discriminate ApproverUserId → lấy row đầu DB.
|
||||||
|
//
|
||||||
|
// Bro UAT 2026-05-15 phát hiện sau Plan N deploy: Actor non-row-đầu click
|
||||||
|
// "Trả lại" → toast "Không phải lượt bạn — chỉ NV Cấp duyệt hiện tại mới
|
||||||
|
// được Trả lại / Từ chối phiếu" mặc dù actor TRONG slot.
|
||||||
|
//
|
||||||
|
// 4 sites fixed Plan O:
|
||||||
|
// 1. PurchaseEvaluationWorkflowService.cs:201 — EnsureCanRejectV2Async (bug bro UAT 1)
|
||||||
|
// 2. PurchaseEvaluationWorkflowService.cs:248 — ApplyReturnModeAsync (cùng bug)
|
||||||
|
// 3. PurchaseEvaluationDetailFeatures.cs:72 — F3 EnsureEditableForDetailsAsync
|
||||||
|
// 4. PurchaseEvaluationFeatures.cs:311 — F4 AdjustBudgetCommand
|
||||||
|
//
|
||||||
|
// Test cover 2 site Service.cs qua TransitionAsync entry point.
|
||||||
|
// 2 site Features.cs đã có test existing (PurchaseEvaluationDraftGuardTests
|
||||||
|
// + implicit qua command handler) — không add thêm.
|
||||||
|
public class PurchaseEvaluationPerNvLookupRegressionTests
|
||||||
|
{
|
||||||
|
private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix, TestApplicationDbContext db)
|
||||||
|
CreateService()
|
||||||
|
{
|
||||||
|
var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var dt = new FixedDateTime(new DateTime(2026, 5, 15, 12, 0, 0, DateTimeKind.Utc));
|
||||||
|
var notify = new NoOpNotificationService();
|
||||||
|
var svc = new PurchaseEvaluationWorkflowService(db, dt, notify, um);
|
||||||
|
return (svc, fix, db);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(ApprovalWorkflow wf, Guid lAId, Guid lBId, Guid lCId, Guid lDId)>
|
||||||
|
Seed1StepOrOfNAsync(TestApplicationDbContext db,
|
||||||
|
Guid uA, Guid uB, Guid uC, Guid uD,
|
||||||
|
bool aAllowReturn, bool bAllowReturn, bool cAllowReturn, bool dAllowReturn)
|
||||||
|
{
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = "QT-O-001",
|
||||||
|
Version = 1,
|
||||||
|
Name = "Plan O OR-of-N regression",
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
|
||||||
|
IsActive = true,
|
||||||
|
};
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowId = wf.Id,
|
||||||
|
Order = 1,
|
||||||
|
Name = "Bước 1 (4 NV OR-of-N cùng Cấp)",
|
||||||
|
};
|
||||||
|
var lA = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = 1,
|
||||||
|
ApproverUserId = uA,
|
||||||
|
AllowReturnOneLevel = aAllowReturn,
|
||||||
|
AllowReturnToDrafter = true,
|
||||||
|
};
|
||||||
|
var lB = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = 1,
|
||||||
|
ApproverUserId = uB,
|
||||||
|
AllowReturnOneLevel = bAllowReturn,
|
||||||
|
AllowReturnToDrafter = true,
|
||||||
|
};
|
||||||
|
var lC = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = 1,
|
||||||
|
ApproverUserId = uC,
|
||||||
|
AllowReturnOneLevel = cAllowReturn,
|
||||||
|
AllowReturnToDrafter = true,
|
||||||
|
};
|
||||||
|
var lD = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = 1,
|
||||||
|
ApproverUserId = uD,
|
||||||
|
AllowReturnOneLevel = dAllowReturn,
|
||||||
|
AllowReturnToDrafter = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
db.ApprovalWorkflowSteps.Add(step);
|
||||||
|
db.ApprovalWorkflowLevels.AddRange(lA, lB, lC, lD);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return (wf, lA.Id, lB.Id, lC.Id, lD.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<PurchaseEvaluation> SeedPeChoDuyetAsync(
|
||||||
|
TestApplicationDbContext db, Guid drafterId, Guid workflowId)
|
||||||
|
{
|
||||||
|
var pe = new PurchaseEvaluation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = PurchaseEvaluationType.DuyetNcc,
|
||||||
|
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||||
|
MaPhieu = $"PE-O-{Guid.NewGuid().ToString("N").Substring(0, 6)}",
|
||||||
|
TenGoiThau = "Plan O regression",
|
||||||
|
ProjectId = Guid.NewGuid(),
|
||||||
|
DrafterUserId = drafterId,
|
||||||
|
ApprovalWorkflowId = workflowId,
|
||||||
|
CurrentWorkflowStepIndex = 0,
|
||||||
|
CurrentApprovalLevelOrder = 1,
|
||||||
|
};
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return pe;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Site #1: EnsureCanRejectV2Async (Service.cs:201) — bug bro UAT 1
|
||||||
|
// Pre-fix: handler check `currentLevel?.ApproverUserId != actorId` where
|
||||||
|
// currentLevel = first row by Order. Non-first-row actor luôn fail check.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TransitionReject_ActorD_LastInSlot_AllowsRejectViaDrafterMode()
|
||||||
|
{
|
||||||
|
var (svc, fix, db) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var drafter = await fix.CreateUserAsync("drafter-o1@test.local", "Drafter", null, new[] { AppRoles.Drafter });
|
||||||
|
var a = await fix.CreateUserAsync("a-o1@test.local", "A", null, new[] { AppRoles.CostControl });
|
||||||
|
var b = await fix.CreateUserAsync("b-o1@test.local", "B", null, new[] { AppRoles.CostControl });
|
||||||
|
var c = await fix.CreateUserAsync("c-o1@test.local", "C", null, new[] { AppRoles.CostControl });
|
||||||
|
var d = await fix.CreateUserAsync("d-o1@test.local", "D", null, new[] { AppRoles.CostControl });
|
||||||
|
// All 4 slot allow Drafter mode (default Trả lại)
|
||||||
|
var (wf, _, _, _, _) = await Seed1StepOrOfNAsync(db, a.Id, b.Id, c.Id, d.Id, false, false, false, false);
|
||||||
|
var pe = await SeedPeChoDuyetAsync(db, drafter.Id, wf.Id);
|
||||||
|
|
||||||
|
// Actor D = NON-first-row in OR-of-N slot.
|
||||||
|
// Pre-Plan O bug: handler chỉ check Levels[0]=A → ApproverUserId(A) != actorId(D) → throw "Không phải lượt bạn"
|
||||||
|
// Post-Plan O fix: handler filter `Order==X && ApproverUserId==actorId` → match D row → allow.
|
||||||
|
var act = async () => await svc.TransitionAsync(
|
||||||
|
pe, PurchaseEvaluationPhase.TraLai, d.Id, new[] { AppRoles.CostControl },
|
||||||
|
ApprovalDecision.Reject, "Test Plan O Actor D Trả lại");
|
||||||
|
|
||||||
|
await act.Should().NotThrowAsync(
|
||||||
|
"Plan O O1+O2 fix: actor D match slot → cho phép Trả lại Drafter mode. " +
|
||||||
|
"Pre-fix bug: throw 'Không phải lượt bạn' vì handler chỉ check row đầu DB.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TransitionReject_Outsider_NotInSlot_ThrowsForbidden()
|
||||||
|
{
|
||||||
|
var (svc, fix, db) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var drafter = await fix.CreateUserAsync("drafter-o2@test.local", "Drafter", null, new[] { AppRoles.Drafter });
|
||||||
|
var a = await fix.CreateUserAsync("a-o2@test.local", "A", null, new[] { AppRoles.CostControl });
|
||||||
|
var b = await fix.CreateUserAsync("b-o2@test.local", "B", null, new[] { AppRoles.CostControl });
|
||||||
|
var c = await fix.CreateUserAsync("c-o2@test.local", "C", null, new[] { AppRoles.CostControl });
|
||||||
|
var d = await fix.CreateUserAsync("d-o2@test.local", "D", null, new[] { AppRoles.CostControl });
|
||||||
|
var outsider = await fix.CreateUserAsync("outsider@test.local", "Outsider", null, new[] { AppRoles.CostControl });
|
||||||
|
var (wf, _, _, _, _) = await Seed1StepOrOfNAsync(db, a.Id, b.Id, c.Id, d.Id, false, false, false, false);
|
||||||
|
var pe = await SeedPeChoDuyetAsync(db, drafter.Id, wf.Id);
|
||||||
|
|
||||||
|
// Outsider không trong slot OR-of-N → throw đúng intent
|
||||||
|
var act = async () => await svc.TransitionAsync(
|
||||||
|
pe, PurchaseEvaluationPhase.TraLai, outsider.Id, new[] { AppRoles.CostControl },
|
||||||
|
ApprovalDecision.Reject, "Outsider test");
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ForbiddenException>()
|
||||||
|
.WithMessage("*Không phải lượt bạn*",
|
||||||
|
"Plan O fix: outsider không có trong slot OR-of-N → throw đúng nghiệp vụ. " +
|
||||||
|
"Verify fix KHÔNG over-permissive (cho phép outsider).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Site #2: ApplyReturnModeAsync (Service.cs:248) — read Allow flag từ Level slot
|
||||||
|
// Pre-fix: currentLevel = first row → flag check dùng row đầu thay vì actor row.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TransitionRejectOneLevel_ActorC_HasFlagWhileOthersDont_AllowsMode()
|
||||||
|
{
|
||||||
|
var (svc, fix, db) = CreateService();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var drafter = await fix.CreateUserAsync("drafter-o3@test.local", "Drafter", null, new[] { AppRoles.Drafter });
|
||||||
|
var a = await fix.CreateUserAsync("a-o3@test.local", "A", null, new[] { AppRoles.CostControl });
|
||||||
|
var b = await fix.CreateUserAsync("b-o3@test.local", "B", null, new[] { AppRoles.CostControl });
|
||||||
|
var c = await fix.CreateUserAsync("c-o3@test.local", "C", null, new[] { AppRoles.CostControl });
|
||||||
|
var d = await fix.CreateUserAsync("d-o3@test.local", "D", null, new[] { AppRoles.CostControl });
|
||||||
|
// Only Actor C tick AllowReturnOneLevel — 3 NV khác KHÔNG tick.
|
||||||
|
// Bug pre-fix: handler read flag từ row đầu (Actor A KHÔNG tick)
|
||||||
|
// → throw ConflictException "Cấp Approver hiện tại không bật mode OneLevel"
|
||||||
|
// mặc dù actor C TICK đúng.
|
||||||
|
var (wf, _, _, _, _) = await Seed1StepOrOfNAsync(db, a.Id, b.Id, c.Id, d.Id, false, false, true, false);
|
||||||
|
var pe = await SeedPeChoDuyetAsync(db, drafter.Id, wf.Id);
|
||||||
|
// Set pointer Step 0 Cấp 1 (slot OR-of-N — actor C có flag)
|
||||||
|
// Đang ở Cấp 1 Bước 1 → fallback Plan M reset (0,1) giữ ChoDuyet — no throw
|
||||||
|
var act = async () => await svc.TransitionAsync(
|
||||||
|
pe, PurchaseEvaluationPhase.ChoDuyet, c.Id, new[] { AppRoles.CostControl },
|
||||||
|
ApprovalDecision.Reject, "Actor C Trả lại 1 Cấp",
|
||||||
|
returnMode: WorkflowReturnMode.OneLevel);
|
||||||
|
|
||||||
|
await act.Should().NotThrowAsync(
|
||||||
|
"Plan O O2 fix: actor C match slot + có flag OneLevel → mode allowed. " +
|
||||||
|
"Pre-fix bug: read flag từ row đầu (A=false) → throw 'không bật mode OneLevel'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user