[CLAUDE] PurchaseEvaluation Tests: Chunk N1+N2 — HOTFIX per-NV lookup site discrimination Allow* flag (BE bug 2 ngày prod)
Bro UAT 2026-05-15 screenshot phát hiện: Admin Designer tick TRUE 7 flag cho
NV Test (UAT V2) slot Bước 2 Cấp 1 (4 NV cùng Cấp, OR-of-N Mig 29). Actor
login → dialog ✓ Duyệt KHÔNG có checkbox F2 skipToFinal + dialog ← Trả lại
CHỈ 1 radio Drafter + KHÔNG có F3+F4 Edit options.
Investigator audit confirm Hypothesis B: BE handler
`PurchaseEvaluationFeatures.cs:765` `FirstOrDefault(l => l.Order ==
curLevelOrder)` THIẾU discriminator `ApproverUserId == currentUser.UserId`.
Schema Mig 29 (S21 t5 2026-05-13) refactor: 1 row per ApproverUserId, OR-of-N
cùng Order → handler luôn lấy row đầu DB (Lê Văn Bính / Trần Xuân Lưu —
chỉ Drafter flag), bỏ qua admin tick per-NV của actor thật.
Bug PRESENT từ Mig 29 deploy 2026-05-13 (2 NGÀY PROD) nhưng chỉ bộc lộ khi
lần đầu admin tick selectively per-NV. Trước đây tất cả slot FALSE → mọi
actor đều thấy "không có options", behavior giống nhau, không lộ.
Cumulative gap analysis: Mig 29 + Mig 30 + Mig 31 wire 8 surface points đúng
nhưng MISS point 9 lookup discrimination → 3× refactor cùng bug. Point 9
mới được catch Plan N S23 t4 (em main + Reviewer + Implementer all MISS
xuyên 3 plan).
N1 BE fix (5 LOC line 765-779):
```csharp
var curLevel = curStep?.Levels.FirstOrDefault(l =>
l.Order == curLevelOrder && l.ApproverUserId == currentUser.UserId)
?? curStep?.Levels.FirstOrDefault(l => l.Order == curLevelOrder); // admin/non-approver fallback
```
N2 Regression test (new file `GetPurchaseEvaluationCurrentLevelOptionsTests.cs`):
- `GetPe_PerNvLookup_ActorMatchesSlot_ReturnsActorSpecificFlags`:
Seed 4 Level cùng Order=1 (mỗi Level distinct flag profile) × 4 actor →
assert mỗi actor nhận flag riêng (KHÔNG profile khác). Critical assertion:
Actor C → AllowApproverSkipToFinal=true (bug bro UAT regression).
- `GetPe_PerNvLookup_AdminNonApprover_FallsBackToFirstRow`:
Admin actor (NON-match) → fallback FirstOrDefault EF SQLite non-deterministic
→ weak assert NOT null + match exactly 1 of 4 distinct profile.
Pattern reusable saved memory `feedback_per_nv_permission_scope.md` CRITICAL
HOTFIX S23 t4 section:
- Wire checklist 9 surface points (NOT 8 — thêm point 9 lookup discrimination)
- Audit cho future flag F5+: grep `FirstOrDefault.*Order ==` enumerate all
lookup sites, verify discriminator role-context
Verify:
- dotnet build src/Backend/SolutionErp.Application clean (0 warning, 0 error)
- dotnet test SolutionErp.slnx **108/108 PASS** (+2 từ 106: 58 Domain + 50 Infra)
- N2 2 test individual PASS
Pending Chunk N4: docs + memory update commit + push remote.
Pending CICD Monitor post-deploy verify (spawn sau push).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -757,12 +757,21 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
// Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — Resolve
|
// Mig 29 (S21 t5) + Mig 30 (S22+5) + Mig 31 (S23 t1) — Resolve
|
||||||
// Cấp hiện tại + populate 7 Allow* flag của slot Approver đang
|
// Cấp hiện tại + populate 7 Allow* flag của slot Approver đang
|
||||||
// duyệt. Null nếu pointer chưa init.
|
// duyệt. Null nếu pointer chưa init.
|
||||||
|
//
|
||||||
|
// Plan N S23 t4 (2026-05-15) — Per-NV lookup site MUST discriminate
|
||||||
|
// theo ApproverUserId. Schema Mig 29: OR-of-N approvers cùng Cấp =
|
||||||
|
// N rows cùng Order, mỗi row có Allow* riêng. Lookup KHÔNG
|
||||||
|
// discriminate → luôn lấy row đầu DB, bỏ qua admin tick per-NV.
|
||||||
if (e.CurrentWorkflowStepIndex is int curStepIdx
|
if (e.CurrentWorkflowStepIndex is int curStepIdx
|
||||||
&& curStepIdx >= 0 && curStepIdx < aw.Steps.Count
|
&& curStepIdx >= 0 && curStepIdx < aw.Steps.Count
|
||||||
&& e.CurrentApprovalLevelOrder is int curLevelOrder)
|
&& e.CurrentApprovalLevelOrder is int curLevelOrder)
|
||||||
{
|
{
|
||||||
var curStep = aw.Steps.OrderBy(s => s.Order).Skip(curStepIdx).FirstOrDefault();
|
var curStep = aw.Steps.OrderBy(s => s.Order).Skip(curStepIdx).FirstOrDefault();
|
||||||
var curLevel = curStep?.Levels.FirstOrDefault(l => l.Order == curLevelOrder);
|
// Match actor.UserId trong slot (per-NV admin opt-in flag).
|
||||||
|
// Admin / non-approver fallback row đầu (giữ behavior view detail).
|
||||||
|
var curLevel = curStep?.Levels.FirstOrDefault(l =>
|
||||||
|
l.Order == curLevelOrder && l.ApproverUserId == currentUser.UserId)
|
||||||
|
?? curStep?.Levels.FirstOrDefault(l => l.Order == curLevelOrder);
|
||||||
if (curLevel is not null)
|
if (curLevel is not null)
|
||||||
{
|
{
|
||||||
currentLevelOptions = new ApprovalWorkflowOptionsDto(
|
currentLevelOptions = new ApprovalWorkflowOptionsDto(
|
||||||
|
|||||||
@ -0,0 +1,227 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.PurchaseEvaluations;
|
||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.Master;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
using SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||||
|
|
||||||
|
// Plan N S23 t4 (2026-05-15) — Regression test cho BE bug per-NV lookup site
|
||||||
|
// PurchaseEvaluationFeatures.cs:765. Schema Mig 29 (S21 t5) refactor:
|
||||||
|
// 1 ApprovalWorkflowLevel row per ApproverUserId (OR-of-N cùng Order).
|
||||||
|
// Bug: handler chỉ FirstOrDefault(Order==X) thiếu discriminator ApproverUserId
|
||||||
|
// → luôn lấy row đầu DB → bỏ qua admin tick per-NV của actor thật.
|
||||||
|
//
|
||||||
|
// Fix line 765: thêm `l.ApproverUserId == currentUser.UserId` match +
|
||||||
|
// fallback row đầu cho admin / non-approver view detail.
|
||||||
|
public class GetPurchaseEvaluationCurrentLevelOptionsTests
|
||||||
|
{
|
||||||
|
private sealed class FakeCurrentUser : ICurrentUser
|
||||||
|
{
|
||||||
|
public Guid? UserId { get; init; }
|
||||||
|
public string? Email { get; init; }
|
||||||
|
public string? FullName { get; init; }
|
||||||
|
public IReadOnlyList<string> Roles { get; init; } = Array.Empty<string>();
|
||||||
|
public bool IsAuthenticated => UserId is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(ApprovalWorkflow wf, List<ApprovalWorkflowLevel> levels)>
|
||||||
|
SeedWorkflowWith4SlotsAsync(
|
||||||
|
TestApplicationDbContext db,
|
||||||
|
Guid approverAId, Guid approverBId, Guid approverCId, Guid approverDId)
|
||||||
|
{
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = "QT-N-001",
|
||||||
|
Version = 1,
|
||||||
|
Name = "Plan N regression workflow",
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
};
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowId = wf.Id,
|
||||||
|
Order = 1,
|
||||||
|
Name = "Bước 1",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4 Level cùng Order=1, mỗi Level approverUserId khác nhau, FLAG khác nhau
|
||||||
|
// theo matrix bug bro phát hiện 2026-05-15:
|
||||||
|
// Level A (row đầu) — Drafter only TRUE, 6 flag khác FALSE
|
||||||
|
// Level B — OneLevel TRUE
|
||||||
|
// Level C — SkipToFinal TRUE
|
||||||
|
// Level D — EditBudget TRUE
|
||||||
|
var levelA = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = 1,
|
||||||
|
ApproverUserId = approverAId,
|
||||||
|
AllowReturnToDrafter = true,
|
||||||
|
};
|
||||||
|
// Note: Mig 29 set HasDefaultValue(true) cho AllowReturnToDrafter — phải
|
||||||
|
// explicit FALSE cho Level B/C/D để test discrimination chuẩn xác.
|
||||||
|
var levelB = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = 1,
|
||||||
|
ApproverUserId = approverBId,
|
||||||
|
AllowReturnOneLevel = true,
|
||||||
|
AllowReturnToDrafter = false,
|
||||||
|
};
|
||||||
|
var levelC = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = 1,
|
||||||
|
ApproverUserId = approverCId,
|
||||||
|
AllowApproverSkipToFinal = true,
|
||||||
|
AllowReturnToDrafter = false,
|
||||||
|
};
|
||||||
|
var levelD = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = 1,
|
||||||
|
ApproverUserId = approverDId,
|
||||||
|
AllowApproverEditBudget = true,
|
||||||
|
AllowReturnToDrafter = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
db.ApprovalWorkflowSteps.Add(step);
|
||||||
|
db.ApprovalWorkflowLevels.AddRange(levelA, levelB, levelC, levelD);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
return (wf, new List<ApprovalWorkflowLevel> { levelA, levelB, levelC, levelD });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<PurchaseEvaluation> SeedPeAsync(
|
||||||
|
TestApplicationDbContext db, Guid drafterId, Guid awId)
|
||||||
|
{
|
||||||
|
var project = new Project
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = "PRJ-N-001",
|
||||||
|
Name = "Plan N test project",
|
||||||
|
};
|
||||||
|
db.Projects.Add(project);
|
||||||
|
|
||||||
|
var pe = new PurchaseEvaluation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = PurchaseEvaluationType.DuyetNcc,
|
||||||
|
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||||
|
MaPhieu = "PE-N-001",
|
||||||
|
TenGoiThau = "Plan N regression test",
|
||||||
|
ProjectId = project.Id,
|
||||||
|
DrafterUserId = drafterId,
|
||||||
|
ApprovalWorkflowId = awId,
|
||||||
|
CurrentWorkflowStepIndex = 0,
|
||||||
|
CurrentApprovalLevelOrder = 1,
|
||||||
|
};
|
||||||
|
db.PurchaseEvaluations.Add(pe);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return pe;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GetPurchaseEvaluationQueryHandler BuildHandler(
|
||||||
|
IdentityFixture fix, FakeCurrentUser actor)
|
||||||
|
{
|
||||||
|
return new GetPurchaseEvaluationQueryHandler(
|
||||||
|
fix.Services.GetRequiredService<IApplicationDbContext>(),
|
||||||
|
fix.Services.GetRequiredService<UserManager<User>>(),
|
||||||
|
actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPe_PerNvLookup_ActorMatchesSlot_ReturnsActorSpecificFlags()
|
||||||
|
{
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var drafter = await fix.CreateUserAsync("drafter-n@test.local", "Drafter N", null, new[] { AppRoles.Drafter });
|
||||||
|
var a = await fix.CreateUserAsync("a@test.local", "Approver A", null, new[] { AppRoles.CostControl });
|
||||||
|
var b = await fix.CreateUserAsync("b@test.local", "Approver B", null, new[] { AppRoles.CostControl });
|
||||||
|
var c = await fix.CreateUserAsync("c@test.local", "Approver C", null, new[] { AppRoles.CostControl });
|
||||||
|
var d = await fix.CreateUserAsync("d@test.local", "Approver D", null, new[] { AppRoles.CostControl });
|
||||||
|
var (wf, _) = await SeedWorkflowWith4SlotsAsync(db, a.Id, b.Id, c.Id, d.Id);
|
||||||
|
var pe = await SeedPeAsync(db, drafter.Id, wf.Id);
|
||||||
|
|
||||||
|
// Actor A → flag-set A: AllowReturnToDrafter=true only
|
||||||
|
var resA = await BuildHandler(fix, new FakeCurrentUser
|
||||||
|
{ UserId = a.Id, Roles = new[] { AppRoles.CostControl } })
|
||||||
|
.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||||
|
resA.CurrentLevelOptions.Should().NotBeNull();
|
||||||
|
resA.CurrentLevelOptions!.AllowReturnToDrafter.Should().BeTrue("Actor A tick AllowReturnToDrafter");
|
||||||
|
resA.CurrentLevelOptions.AllowReturnOneLevel.Should().BeFalse();
|
||||||
|
resA.CurrentLevelOptions.AllowApproverSkipToFinal.Should().BeFalse();
|
||||||
|
resA.CurrentLevelOptions.AllowApproverEditBudget.Should().BeFalse();
|
||||||
|
|
||||||
|
// Actor B → flag-set B: AllowReturnOneLevel=true (KHÔNG phải A's flag)
|
||||||
|
var resB = await BuildHandler(fix, new FakeCurrentUser
|
||||||
|
{ UserId = b.Id, Roles = new[] { AppRoles.CostControl } })
|
||||||
|
.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||||
|
resB.CurrentLevelOptions!.AllowReturnOneLevel.Should().BeTrue("Actor B tick AllowReturnOneLevel");
|
||||||
|
resB.CurrentLevelOptions.AllowReturnToDrafter.Should().BeFalse();
|
||||||
|
resB.CurrentLevelOptions.AllowApproverSkipToFinal.Should().BeFalse();
|
||||||
|
|
||||||
|
// Actor C → flag-set C: AllowApproverSkipToFinal=true (regression bug bro UAT chính)
|
||||||
|
var resC = await BuildHandler(fix, new FakeCurrentUser
|
||||||
|
{ UserId = c.Id, Roles = new[] { AppRoles.CostControl } })
|
||||||
|
.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||||
|
resC.CurrentLevelOptions!.AllowApproverSkipToFinal.Should().BeTrue(
|
||||||
|
"Actor C tick AllowApproverSkipToFinal — regression bug bro phát hiện 2026-05-15 UAT");
|
||||||
|
resC.CurrentLevelOptions.AllowReturnToDrafter.Should().BeFalse();
|
||||||
|
|
||||||
|
// Actor D → flag-set D: AllowApproverEditBudget=true
|
||||||
|
var resD = await BuildHandler(fix, new FakeCurrentUser
|
||||||
|
{ UserId = d.Id, Roles = new[] { AppRoles.CostControl } })
|
||||||
|
.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||||
|
resD.CurrentLevelOptions!.AllowApproverEditBudget.Should().BeTrue("Actor D tick AllowApproverEditBudget");
|
||||||
|
resD.CurrentLevelOptions.AllowApproverSkipToFinal.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPe_PerNvLookup_AdminNonApprover_FallsBackToFirstRow()
|
||||||
|
{
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var drafter = await fix.CreateUserAsync("drafter-n2@test.local", "Drafter N2", null, new[] { AppRoles.Drafter });
|
||||||
|
var a = await fix.CreateUserAsync("a2@test.local", "Approver A2", null, new[] { AppRoles.CostControl });
|
||||||
|
var b = await fix.CreateUserAsync("b2@test.local", "Approver B2", null, new[] { AppRoles.CostControl });
|
||||||
|
var c = await fix.CreateUserAsync("c2@test.local", "Approver C2", null, new[] { AppRoles.CostControl });
|
||||||
|
var d = await fix.CreateUserAsync("d2@test.local", "Approver D2", null, new[] { AppRoles.CostControl });
|
||||||
|
var admin = await fix.CreateUserAsync("admin-n@test.local", "Admin N", null, new[] { AppRoles.Admin });
|
||||||
|
var (wf, levels) = await SeedWorkflowWith4SlotsAsync(db, a.Id, b.Id, c.Id, d.Id);
|
||||||
|
var pe = await SeedPeAsync(db, drafter.Id, wf.Id);
|
||||||
|
|
||||||
|
// Admin actor (KHÔNG match approver slot nào) → fallback FirstOrDefault
|
||||||
|
// theo internal EF SQLite ordering (non-deterministic Guid Id).
|
||||||
|
// Verify: fallback hoạt động (KHÔNG null) + match 1 trong 4 profile
|
||||||
|
// distinct (mỗi Level distinct flag → fallback pick exactly 1 profile).
|
||||||
|
var resAdmin = await BuildHandler(fix, new FakeCurrentUser
|
||||||
|
{ UserId = admin.Id, Roles = new[] { AppRoles.Admin } })
|
||||||
|
.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||||
|
resAdmin.CurrentLevelOptions.Should().NotBeNull(
|
||||||
|
"Admin/non-approver fallback row đầu — giữ behavior view detail (KHÔNG null)");
|
||||||
|
// Verify fallback đúng 1 profile (mỗi Level distinct flag):
|
||||||
|
// Level A: AllowReturnToDrafter=true
|
||||||
|
// Level B: AllowReturnOneLevel=true
|
||||||
|
// Level C: AllowApproverSkipToFinal=true
|
||||||
|
// Level D: AllowApproverEditBudget=true
|
||||||
|
var flagCount = (resAdmin.CurrentLevelOptions!.AllowReturnToDrafter ? 1 : 0)
|
||||||
|
+ (resAdmin.CurrentLevelOptions.AllowReturnOneLevel ? 1 : 0)
|
||||||
|
+ (resAdmin.CurrentLevelOptions.AllowApproverSkipToFinal ? 1 : 0)
|
||||||
|
+ (resAdmin.CurrentLevelOptions.AllowApproverEditBudget ? 1 : 0);
|
||||||
|
flagCount.Should().Be(1,
|
||||||
|
"Admin fallback pick exactly 1 trong 4 distinct Level profile");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user