From 6b1e2d922054e63bc88da083e44c2d1fefae7a73 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Thu, 14 May 2026 23:41:26 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20Tests:=20Chunk=20F=20=E2=80=94=20K7?= =?UTF-8?q?=20Mig=2031=20Approver=20F2=20service=20regression=20+=20delete?= =?UTF-8?q?=20deprecated=20Drafter=20F2=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-task 1: Fix broken references - Tests reading removed User.AllowDrafterSkipToFinal prop -> delete entire test methods (semantic deprecated, no value) - 3 deleted: SkipToFinal_DrafterAllowed_SetsPointerToFinalLevel + SkipToFinal_DrafterDenied_NonAdmin_Throws + SkipToFinal_AdminBypass_Succeeds Sub-task 2: Add 3 Approver F2 service tests (PurchaseEvaluationWorkflowServiceReturnModeTests) - ApproveV2_SkipToFinal_AdminTickFlag_SetsPhaseDaDuyet (happy path) - ApproveV2_SkipToFinal_FlagOff_NonAdmin_ThrowsConflictException (denied) - ApproveV2_SkipToFinal_FlagOff_Admin_BypassesFlagCheck (admin bypass) Pattern reusable: SeedApproverF2WorkflowAsync 2 Step x 2 Level cookie-cutter (Implementer memory Pattern 11 S22 SeedWorkflowAsync). PE init Phase=ChoDuyet + pointer Step 0 Cap 1. TestApplicationDbContext SQLite. Add EntityFrameworkCore using for ToListAsync queries on PEL/PEA/Changelog audit assertions. Verify: - dotnet build SolutionErp.slnx 0 err 2 pre-existing DocxRenderer warn - dotnet test SolutionErp.slnx 104 PASS (58 Domain + 46 Infra, 3 deleted + 3 added cancel out, baseline preserved) - 3 Approver F2 tests verified individually PASS Plan K Chunk F test-after carry per Phase 9 UAT mode bro confirm. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/agent-memory/implementer/MEMORY.md | 1 + ...valuationWorkflowServiceReturnModeTests.cs | 319 +++++++++++++----- 2 files changed, 235 insertions(+), 85 deletions(-) diff --git a/.claude/agent-memory/implementer/MEMORY.md b/.claude/agent-memory/implementer/MEMORY.md index a497c06..e9c8a96 100644 --- a/.claude/agent-memory/implementer/MEMORY.md +++ b/.claude/agent-memory/implementer/MEMORY.md @@ -204,6 +204,7 @@ KHÔNG `*` / `latest`. Critical pins: ## 📅 Recent activity (last 10 FIFO) +- **2026-05-14 (S23 t1, K7 Chunk F PASS):** Mig 31 Approver F2 service regression tests. Sub-task 1 fix broken Drafter F2 test reference K1 flagged: `PurchaseEvaluationWorkflowServiceReturnModeTests.cs:253` `drafter.AllowDrafterSkipToFinal = true` (DELETE 3 deprecated Drafter F2 tests entire — `SkipToFinal_DrafterAllowed_SetsPointerToFinalLevel` + `SkipToFinal_DrafterDenied_NonAdmin_Throws` + `SkipToFinal_AdminBypass_Succeeds`, semantic deprecated no value). Sub-task 2 add 3 new Approver F2 tests: `ApproveV2_SkipToFinal_AdminTickFlag_SetsPhaseDaDuyet` (happy path — slot Cấp 1 Bước 1 admin tick → Phase=DaDuyet, pointer cleared, opinion + PEA + Changelog logged), `ApproveV2_SkipToFinal_FlagOff_NonAdmin_ThrowsConflictException` (denied — flag off non-admin slot user → throw "chưa được phép duyệt thẳng Cấp cuối"), `ApproveV2_SkipToFinal_FlagOff_Admin_BypassesFlagCheck` (admin bypass — flag off admin role → DaDuyet allowed). Pattern 11 SeedWorkflowAsync cookie-cutter REUSE — created `SeedApproverF2WorkflowAsync` helper (2 Steps × 2 Levels — multi-step verify skip thẳng terminal KHÔNG fallthrough advance pointer next Step), AllowApproverSkipToFinal per-slot param. PE init Phase=ChoDuyet + CurrentWorkflowStepIndex=0 + CurrentApprovalLevelOrder=1 (vs S22 happy path từ DangSoanThao). Add `using Microsoft.EntityFrameworkCore` cho `.ToListAsync()` PEL/PEA/Changelog query. File header narrative line 17-25 REWRITE để track Mig 31 refactor semantic Approver scope ChoDuyet vs Drafter-from-Nháp cũ. Verify: `dotnet build` clean 0 err 2 warn pre-existing DocxRenderer. `dotnet test SolutionErp.slnx` 104 PASS (58 Domain + 46 Infra, 3 deleted + 3 added cancel out, baseline preserved). 3 Approver F2 tests verified individually PASS. Diff +175/-92 LOC trên 1 file. Token ~14k. - **2026-05-14 (S23 t1, K5 Chunk D PASS):** Cleanup zombie F2 endpoint + UsersPage column + DTO field + stale narrative comments (Reviewer Major #1 + Major #2 + Minor #3 + Minor #4). Pattern post-refactor full cleanup atomic 1 commit. BE 7 file (UsersController.cs DELETE PATCH /allow-skip-final endpoint + SetAllowDrafterSkipToFinalBody record; UserFeatures.cs DELETE UserDto field + SetUserAllowDrafterSkipToFinalCommand + Handler + sentinel-false mappings cleanup; ApprovalWorkflow.cs REWRITE stale narrative line 78-80 Mig 31 semantic + docstring line 108; PurchaseEvaluationFeatures.cs REWRITE Command DTO comment line 401; ApprovalWorkflowConfiguration.cs APPEND Mig 31 narrative line 22-24 + clean storage move comment line 87; ApprovalWorkflowV2AdminFeatures.cs clean DTO comment line 58; IPurchaseEvaluationWorkflowService.cs + PurchaseEvaluationDtos.cs clean stale "storage Users.AllowDrafterSkipToFinal" references) + FE Admin 2 file (UsersPage.tsx DELETE "Skip cuối" column TableHeader/TableCell + FastForward import + allowSkipMut mutation hook + FastForward toggle button; types/users.ts DELETE allowDrafterSkipToFinal field). fe-user KHÔNG đụng (no UsersPage admin-only + K6 sẽ handle Workspace Drafter checkbox), FE Designer page KHÔNG đụng (K3 done — 2 stale comment line 75 + 504 leftover deferred K6). Grep `AllowDrafterSkipToFinal` + `allow-skip-final` + `allowDrafterSkipToFinal` + `Skip cuối` + `FastForward` ZERO results across src/Backend (excl migrations) + fe-admin/src. Build BE production projects clean (0 err, 2 pre-existing DocxRenderer warn). Build fe-admin clean (0 TS err, 0 new warn). Diff +42/-94 LOC trên 9 file. Token ~12k. K6 Workspace Drafter checkbox cleanup next. - **2026-05-14 (S23 t1, K3 Chunk C PASS):** FE Admin Designer 7th checkbox AllowApproverSkipToFinal + banner rewrite. Pattern Mig 29/30 admin opt-in per-slot mirror **reinforced 3×** cumulative (Mig 29 F1+F3 5 checkbox + Mig 30 F4 1 checkbox + Mig 31 F2-refactor 1 checkbox = 7 checkbox total per slot). Cookie-cutter 1 file fe-admin only (`ApprovalWorkflowsV2Page.tsx`, fe-user no Designer per Investigator K0 S1). 7 sub-items atomic: (1) LevelDto type +`allowApproverSkipToFinal: boolean`, (2) EditLevelEntry type +same, (3) `makeDefaultLevelEntry` default false, (4) `copyFromDefinition` propagate `?? false`, (5) inline checkbox row position **cuối list** sau F4 AllowApproverEditBudget logical grouping (Edit Section 2 → Edit Budget → Skip to Final), (6) banner rewrite line ~623 từ "F2 cấu hình ở User Management" (Plan D S22 stale) → "Cấu hình quyền duyệt riêng cho từng NV trong slot Approver bên dưới (Trả lại / Edit Section 2 / Edit Budget / Duyệt thẳng Cấp cuối)", (7) POST/PATCH mutation body `levels.map` +allowApproverSkipToFinal. Verify: `npm run build` fe-admin PASS clean 0 TS error, 0 new warning. Bundle 1395.74 KB (unchanged trivial vs baseline). Diff +26/-7 LOC. Token ~6k. K5 next chunk cleanup zombie endpoint + UsersPage column. - **2026-05-14 (S23 t1, K1 Chunk A PASS):** Mig 31 schema swap F2 storage Users → ApprovalWorkflowLevels. Pattern Mig 29 ADD-DROP no-BACKFILL Option A (accept lose 4 prod user value `fin.pp` + `pm.nv` + `nv.test` + `truong.nguyen`). Cookie-cutter 6 BE file (User.cs -1 prop + ApprovalWorkflow.cs +1 prop `AllowApproverSkipToFinal` per-Approver-slot + ApprovalWorkflowConfiguration.cs +HasDefaultValue + PurchaseEvaluationWorkflowService.cs surgical -37 LOC F2 Drafter SUBMIT branch line 121-157 stub + Mig 3-file). TransitionAsync `bool skipToFinal` 8th param KEPT cho K2 repurpose APPROVE STEP. 4 Application compile-break sites (UserFeatures.cs LIST + GET DTO mapping + SetUserAllowDrafterSkipToFinalCommandHandler NoOp + PurchaseEvaluationFeatures.cs drafter flag = false) patched với sentinel `false` + K2 marker comment (DTO/Command signature unchanged per spec — K2 sẽ refactor). Mig 31 Up() manual reorder ADD-DROP correct (no BACKFILL). Both DBs Dev + Design applied successful. Build production projects clean 0 err 0 warn. Test compile error `PurchaseEvaluationWorkflowServiceReturnModeTests.cs:253` left for K7 chunk (spec exclude test scope). Pattern `feedback_per_nv_permission_scope.md` reinforced 3× cumulative (Mig 29 F1+F3 + Mig 30 F4 + Mig 31 F2-refactor). UserConfiguration.cs file không tồn tại — User entity configured inline `ApplicationDbContext.OnModelCreating` ~line 86, không có HasDefaultValue cho `AllowDrafterSkipToFinal`, EF picks prop change tự động. diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceReturnModeTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceReturnModeTests.cs index a7f160b..33a9f92 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceReturnModeTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationWorkflowServiceReturnModeTests.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Notifications; @@ -14,9 +15,12 @@ using SolutionErp.Infrastructure.Tests.Common; namespace SolutionErp.Infrastructure.Tests.Services; -// Plan C task 1-2 catch-up cho S21 t4-t5 feature: +// Plan C task 1-2 catch-up cho S21 t4-t5 feature + Plan K Chunk F (S23 t1 Mig 31): // - ApplyReturnModeAsync 4 mode đọc level.Allow* per-NV (Mig 29 refactor từ workflow-level Mig 28) -// - skipToFinal đọc user.AllowDrafterSkipToFinal per-Drafter (Mig 29 split scope theo role) +// - skipToFinal (Mig 31 refactor) đọc matchingLevel.AllowApproverSkipToFinal per-Approver-slot +// trong ApproveV2Async branch APPROVE STEP. Semantic mới: Approver during ChoDuyet +// skip thẳng Cấp cuối → Phase=DaDuyet terminal. Semantic cũ Drafter-from-Nháp +// (Mig 29) đã deprecated + storage trên Users table đã drop trong Mig 31 Plan K. // // Focus: defensive boundary check + admin bypass invariant. KHÔNG cover toàn bộ // edge case (Bước 1 Cấp 1 fallback, Assignee runtime pick, V1 legacy fallback) — @@ -236,33 +240,124 @@ public class PurchaseEvaluationWorkflowServiceReturnModeTests } } - // ============ Task 2: skipToFinal (Drafter trình branch) ============ + // ============ Task 2: skipToFinal — Mig 31 (S23 t1 Plan K Chunk F) ============ + // Semantic mới: Approver during ChoDuyet duyệt thẳng Cấp cuối → Phase=DaDuyet terminal. + // Storage: matchingLevel.AllowApproverSkipToFinal (per-Approver-slot, admin opt-in). + // 3 tests cover happy path + denied + admin bypass — mirror F1 Drafter pattern. + // + // SeedApproverF2WorkflowAsync helper: 2 Steps × 2 Levels (multi-step để verify + // skip thẳng terminal, KHÔNG fallthrough advance pointer next Step). Slot Cấp 1 + // Bước 1 set AllowApproverSkipToFinal per param. + + private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep s1, ApprovalWorkflowLevel s1l1, ApprovalWorkflowLevel s1l2, ApprovalWorkflowStep s2, ApprovalWorkflowLevel s2l1, ApprovalWorkflowLevel s2l2)> + SeedApproverF2WorkflowAsync( + TestApplicationDbContext db, + Guid s1l1Approver, + Guid s1l2Approver, + Guid s2l1Approver, + Guid s2l2Approver, + bool allowApproverSkipToFinalSlotS1L1 = false) + { + var wf = new ApprovalWorkflow + { + Id = Guid.NewGuid(), + Code = "QT-TEST-F2-001", + Version = 1, + Name = "Test Workflow Approver F2", + ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc, + IsActive = true, + IsUserSelectable = true, + }; + var s1 = new ApprovalWorkflowStep + { + Id = Guid.NewGuid(), + ApprovalWorkflowId = wf.Id, + Order = 1, + DepartmentId = null, + Name = "Bước 1 CCM", + }; + var s2 = new ApprovalWorkflowStep + { + Id = Guid.NewGuid(), + ApprovalWorkflowId = wf.Id, + Order = 2, + DepartmentId = null, + Name = "Bước 2 GĐ", + }; + var s1l1 = new ApprovalWorkflowLevel + { + Id = Guid.NewGuid(), + ApprovalWorkflowStepId = s1.Id, + Order = 1, + ApproverUserId = s1l1Approver, + AllowApproverSkipToFinal = allowApproverSkipToFinalSlotS1L1, + }; + var s1l2 = new ApprovalWorkflowLevel + { + Id = Guid.NewGuid(), + ApprovalWorkflowStepId = s1.Id, + Order = 2, + ApproverUserId = s1l2Approver, + }; + var s2l1 = new ApprovalWorkflowLevel + { + Id = Guid.NewGuid(), + ApprovalWorkflowStepId = s2.Id, + Order = 1, + ApproverUserId = s2l1Approver, + }; + var s2l2 = new ApprovalWorkflowLevel + { + Id = Guid.NewGuid(), + ApprovalWorkflowStepId = s2.Id, + Order = 2, + ApproverUserId = s2l2Approver, + }; + db.ApprovalWorkflows.Add(wf); + db.ApprovalWorkflowSteps.Add(s1); + db.ApprovalWorkflowSteps.Add(s2); + db.ApprovalWorkflowLevels.Add(s1l1); + db.ApprovalWorkflowLevels.Add(s1l2); + db.ApprovalWorkflowLevels.Add(s2l1); + db.ApprovalWorkflowLevels.Add(s2l2); + await db.SaveChangesAsync(CancellationToken.None); + return (wf, s1, s1l1, s1l2, s2, s2l1, s2l2); + } [Fact] - public async Task SkipToFinal_DrafterAllowed_SetsPointerToFinalLevel() + public async Task ApproveV2_SkipToFinal_AdminTickFlag_SetsPhaseDaDuyet() { - // Drafter user có AllowDrafterSkipToFinal=true → init pointer cuối step + cuối level. + // Happy path: workflow 2 Step × 2 Level. Slot Cấp 1 Bước 1 admin tick + // AllowApproverSkipToFinal=true. Actor = userA (Cấp 1 Bước 1 approver, + // non-admin role). PE pin workflow + Phase=ChoDuyet + pointer init Step 0 Cấp 1. + // → Phase=DaDuyet, pointer cleared, opinion + PEA + Changelog logged. var (svc, fix, db, _) = CreateService(); using (fix) { - var (a1, a2) = await SeedApproversAsync(fix, "skip1"); - var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id); - var drafter = await fix.CreateUserAsync( - "drafter.skip@test.local", "Drafter Skip", departmentId: null, - roles: new[] { AppRoles.Drafter }); - drafter.AllowDrafterSkipToFinal = true; - await fix.Services.GetRequiredService>().UpdateAsync(drafter); + var userA = await fix.CreateUserAsync("usera-f2-skip@test.local", "User A F2 Skip", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var userB = await fix.CreateUserAsync("userb-f2-skip@test.local", "User B F2 Skip", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var userC = await fix.CreateUserAsync("userc-f2-skip@test.local", "User C F2 Skip", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var userD = await fix.CreateUserAsync("userd-f2-skip@test.local", "User D F2 Skip", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var (wf, _, s1l1, _, _, _, _) = await SeedApproverF2WorkflowAsync( + db, userA.Id, userB.Id, userC.Id, userD.Id, + allowApproverSkipToFinalSlotS1L1: true); var pe = new PurchaseEvaluation { Id = Guid.NewGuid(), Type = PurchaseEvaluationType.DuyetNcc, - Phase = PurchaseEvaluationPhase.DangSoanThao, - MaPhieu = "PE-SKIP-001", - TenGoiThau = "Skip to final", + Phase = PurchaseEvaluationPhase.ChoDuyet, + MaPhieu = "PE-F2-001", + TenGoiThau = "Approver F2 happy", ProjectId = Guid.NewGuid(), - DrafterUserId = drafter.Id, + DrafterUserId = Guid.NewGuid(), ApprovalWorkflowId = wf.Id, + CurrentWorkflowStepIndex = 0, + CurrentApprovalLevelOrder = 1, }; db.PurchaseEvaluations.Add(pe); await db.SaveChangesAsync(CancellationToken.None); @@ -270,44 +365,74 @@ public class PurchaseEvaluationWorkflowServiceReturnModeTests await svc.TransitionAsync( evaluation: pe, targetPhase: PurchaseEvaluationPhase.ChoDuyet, - actorUserId: drafter.Id, - actorRoles: new[] { AppRoles.Drafter }, + actorUserId: userA.Id, + actorRoles: new[] { AppRoles.CostControl }, decision: ApprovalDecision.Approve, - comment: "gửi thẳng cấp cuối", + comment: "duyệt thẳng cấp cuối", skipToFinal: true, ct: CancellationToken.None); - pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet); - pe.CurrentWorkflowStepIndex.Should().Be(0, "Step duy nhất (chỉ 1 Step) = index 0"); - pe.CurrentApprovalLevelOrder.Should().Be(2, - "Final Level Order = 2 (Cấp cuối Bước cuối)"); + pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "Skip → terminal trực tiếp"); + pe.CurrentWorkflowStepIndex.Should().BeNull("Pointer cleared khi terminal"); + pe.CurrentApprovalLevelOrder.Should().BeNull("Pointer cleared khi terminal"); + pe.SlaDeadline.Should().BeNull("SLA cleared khi terminal"); + + // 1 PEL opinion UPSERT cho slot Cấp 1 Bước 1 trước skip + var opinions = await db.PurchaseEvaluationLevelOpinions + .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync(); + opinions.Should().HaveCount(1); + opinions[0].ApprovalWorkflowLevelId.Should().Be(s1l1.Id, "Opinion ghi vào slot Cấp 1 Bước 1"); + opinions[0].SignedByUserId.Should().Be(userA.Id); + + // 1 PEA approval audit + var approvals = await db.PurchaseEvaluationApprovals + .Where(a => a.PurchaseEvaluationId == pe.Id).ToListAsync(); + approvals.Should().HaveCount(1); + approvals[0].ApproverUserId.Should().Be(userA.Id); + approvals[0].Decision.Should().Be(ApprovalDecision.Approve); + + // Changelog entry với context note chứa "Approver duyệt thẳng Cấp cuối" + var changelogs = await db.PurchaseEvaluationChangelogs + .Where(c => c.PurchaseEvaluationId == pe.Id).ToListAsync(); + changelogs.Should().Contain(c => c.ContextNote != null + && c.ContextNote.Contains("Approver duyệt thẳng Cấp cuối")); } } [Fact] - public async Task SkipToFinal_DrafterDenied_NonAdmin_Throws() + public async Task ApproveV2_SkipToFinal_FlagOff_NonAdmin_ThrowsConflictException() { - // Drafter user có AllowDrafterSkipToFinal=false (default) + non-admin → throw. + // Denied: workflow same nhưng AllowApproverSkipToFinal=false cho slot Cấp 1 Bước 1. + // Actor = userA (non-admin, trong slot Cấp 1 Bước 1 approvers). + // → throw ConflictException "chưa được phép duyệt thẳng Cấp cuối". + // State unchanged: Phase still ChoDuyet, pointer unchanged. var (svc, fix, db, _) = CreateService(); using (fix) { - var (a1, a2) = await SeedApproversAsync(fix, "skip2"); - var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id); - var drafter = await fix.CreateUserAsync( - "drafter.noskip@test.local", "Drafter NoSkip", departmentId: null, - roles: new[] { AppRoles.Drafter }); - // drafter.AllowDrafterSkipToFinal = false (default) + var userA = await fix.CreateUserAsync("usera-f2-deny@test.local", "User A F2 Deny", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var userB = await fix.CreateUserAsync("userb-f2-deny@test.local", "User B F2 Deny", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var userC = await fix.CreateUserAsync("userc-f2-deny@test.local", "User C F2 Deny", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var userD = await fix.CreateUserAsync("userd-f2-deny@test.local", "User D F2 Deny", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var (wf, _, _, _, _, _, _) = await SeedApproverF2WorkflowAsync( + db, userA.Id, userB.Id, userC.Id, userD.Id, + allowApproverSkipToFinalSlotS1L1: false); var pe = new PurchaseEvaluation { Id = Guid.NewGuid(), Type = PurchaseEvaluationType.DuyetNcc, - Phase = PurchaseEvaluationPhase.DangSoanThao, - MaPhieu = "PE-SKIP-002", - TenGoiThau = "Skip denied", + Phase = PurchaseEvaluationPhase.ChoDuyet, + MaPhieu = "PE-F2-002", + TenGoiThau = "Approver F2 denied", ProjectId = Guid.NewGuid(), - DrafterUserId = drafter.Id, + DrafterUserId = Guid.NewGuid(), ApprovalWorkflowId = wf.Id, + CurrentWorkflowStepIndex = 0, + CurrentApprovalLevelOrder = 1, }; db.PurchaseEvaluations.Add(pe); await db.SaveChangesAsync(CancellationToken.None); @@ -315,22 +440,87 @@ public class PurchaseEvaluationWorkflowServiceReturnModeTests var act = async () => await svc.TransitionAsync( evaluation: pe, targetPhase: PurchaseEvaluationPhase.ChoDuyet, - actorUserId: drafter.Id, - actorRoles: new[] { AppRoles.Drafter }, + actorUserId: userA.Id, + actorRoles: new[] { AppRoles.CostControl }, decision: ApprovalDecision.Approve, comment: "test denied", skipToFinal: true, ct: CancellationToken.None); await act.Should().ThrowAsync() - .WithMessage("*không được phép gửi thẳng Cấp cuối*"); + .WithMessage("*chưa được phép duyệt thẳng Cấp cuối*"); - // Service mutate Phase=ChoDuyet TRƯỚC khi validate skipToFinal flag, - // throw chặn SaveChangesAsync → DB không persist. Test focus contract - // throw, không assert in-memory rollback (note: nếu future refactor - // move validate trước mutate, test này vẫn pass). - pe.CurrentWorkflowStepIndex.Should().BeNull("Skip flow throw trước khi init pointer"); - pe.CurrentApprovalLevelOrder.Should().BeNull("Pointer chưa init khi throw"); + // State: in-memory PE chưa mutate phase (throw chặn trước assign). + // Note: service log PEA + Opinion TRƯỚC khi validate skipToFinal flag, + // throw chặn SaveChangesAsync → DB không persist. Focus test contract + // throw + Phase invariant unchanged. + pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "Throw chặn trước mutate Phase"); + pe.CurrentWorkflowStepIndex.Should().Be(0, "Pointer unchanged"); + pe.CurrentApprovalLevelOrder.Should().Be(1, "Pointer unchanged"); + } + } + + [Fact] + public async Task ApproveV2_SkipToFinal_FlagOff_Admin_BypassesFlagCheck() + { + // Admin bypass: workflow same nhưng AllowApproverSkipToFinal=false cho slot Cấp 1 Bước 1. + // Actor = adminUser (actorRoles contains "Admin"), trong slot Cấp 1 Bước 1. + // → DaDuyet (admin bypass flag), pointer cleared, opinion logged, Changelog "Approver duyệt thẳng Cấp cuối". + var (svc, fix, db, _) = CreateService(); + using (fix) + { + var adminUser = await fix.CreateUserAsync("admin-f2-bypass@test.local", "Admin F2 Bypass", + departmentId: null, roles: new[] { AppRoles.Admin }); + var userB = await fix.CreateUserAsync("userb-f2-bypass@test.local", "User B F2 Bypass", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var userC = await fix.CreateUserAsync("userc-f2-bypass@test.local", "User C F2 Bypass", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var userD = await fix.CreateUserAsync("userd-f2-bypass@test.local", "User D F2 Bypass", + departmentId: null, roles: new[] { AppRoles.CostControl }); + var (wf, _, _, _, _, _, _) = await SeedApproverF2WorkflowAsync( + db, adminUser.Id, userB.Id, userC.Id, userD.Id, + allowApproverSkipToFinalSlotS1L1: false); + + var pe = new PurchaseEvaluation + { + Id = Guid.NewGuid(), + Type = PurchaseEvaluationType.DuyetNcc, + Phase = PurchaseEvaluationPhase.ChoDuyet, + MaPhieu = "PE-F2-003", + TenGoiThau = "Approver F2 admin bypass", + ProjectId = Guid.NewGuid(), + DrafterUserId = Guid.NewGuid(), + ApprovalWorkflowId = wf.Id, + CurrentWorkflowStepIndex = 0, + CurrentApprovalLevelOrder = 1, + }; + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + + await svc.TransitionAsync( + evaluation: pe, + targetPhase: PurchaseEvaluationPhase.ChoDuyet, + actorUserId: adminUser.Id, + actorRoles: new[] { AppRoles.Admin }, + decision: ApprovalDecision.Approve, + comment: "admin duyệt thẳng", + skipToFinal: true, + ct: CancellationToken.None); + + pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "Admin bypass flag → terminal"); + pe.CurrentWorkflowStepIndex.Should().BeNull("Pointer cleared"); + pe.CurrentApprovalLevelOrder.Should().BeNull("Pointer cleared"); + + // Opinion logged (UPSERT trước skip) + var opinions = await db.PurchaseEvaluationLevelOpinions + .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync(); + opinions.Should().HaveCount(1); + + // Changelog entry với "Approver duyệt thẳng Cấp cuối" + var changelogs = await db.PurchaseEvaluationChangelogs + .Where(c => c.PurchaseEvaluationId == pe.Id).ToListAsync(); + changelogs.Should().Contain(c => c.ContextNote != null + && c.ContextNote.Contains("Approver duyệt thẳng Cấp cuối")); } } @@ -372,45 +562,4 @@ public class PurchaseEvaluationWorkflowServiceReturnModeTests } } - [Fact] - public async Task SkipToFinal_AdminBypass_Succeeds() - { - // Admin role bypass user.AllowDrafterSkipToFinal flag check. - var (svc, fix, db, _) = CreateService(); - using (fix) - { - var (a1, a2) = await SeedApproversAsync(fix, "skip3"); - var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id); - var adminUser = await fix.CreateUserAsync( - "admin.skip@test.local", "Admin Skip", departmentId: null, - roles: new[] { AppRoles.Admin }); - - var pe = new PurchaseEvaluation - { - Id = Guid.NewGuid(), - Type = PurchaseEvaluationType.DuyetNcc, - Phase = PurchaseEvaluationPhase.DangSoanThao, - MaPhieu = "PE-SKIP-003", - TenGoiThau = "Admin skip", - ProjectId = Guid.NewGuid(), - DrafterUserId = adminUser.Id, - ApprovalWorkflowId = wf.Id, - }; - db.PurchaseEvaluations.Add(pe); - await db.SaveChangesAsync(CancellationToken.None); - - await svc.TransitionAsync( - evaluation: pe, - targetPhase: PurchaseEvaluationPhase.ChoDuyet, - actorUserId: adminUser.Id, - actorRoles: new[] { AppRoles.Admin }, - decision: ApprovalDecision.Approve, - comment: "admin gửi thẳng", - skipToFinal: true, - ct: CancellationToken.None); - - pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet); - pe.CurrentApprovalLevelOrder.Should().Be(2, "Admin bypass + skip → pointer cuối"); - } - } }