From 03264581ffbe8d5bee327020810f1f1ff4a2ded6 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Fri, 15 May 2026 12:41:56 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20PurchaseEvaluation=20Tests:=20Chunk?= =?UTF-8?q?=20N1+N2=20=E2=80=94=20HOTFIX=20per-NV=20lookup=20site=20discri?= =?UTF-8?q?mination=20Allow*=20flag=20(BE=20bug=202=20ng=C3=A0y=20prod)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../PurchaseEvaluationFeatures.cs | 11 +- ...chaseEvaluationCurrentLevelOptionsTests.cs | 227 ++++++++++++++++++ 2 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 tests/SolutionErp.Infrastructure.Tests/Application/GetPurchaseEvaluationCurrentLevelOptionsTests.cs diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs index 024a317..074eec5 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs @@ -757,12 +757,21 @@ public class GetPurchaseEvaluationQueryHandler( // 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 // 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 && curStepIdx >= 0 && curStepIdx < aw.Steps.Count && e.CurrentApprovalLevelOrder is int curLevelOrder) { 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) { currentLevelOptions = new ApprovalWorkflowOptionsDto( diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/GetPurchaseEvaluationCurrentLevelOptionsTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/GetPurchaseEvaluationCurrentLevelOptionsTests.cs new file mode 100644 index 0000000..77bf677 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/GetPurchaseEvaluationCurrentLevelOptionsTests.cs @@ -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 Roles { get; init; } = Array.Empty(); + public bool IsAuthenticated => UserId is not null; + } + + private static async Task<(ApprovalWorkflow wf, List 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 { levelA, levelB, levelC, levelD }); + } + + private static async Task 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(), + fix.Services.GetRequiredService>(), + actor); + } + + [Fact] + public async Task GetPe_PerNvLookup_ActorMatchesSlot_ReturnsActorSpecificFlags() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + 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(); + 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"); + } +}