[CLAUDE] Tests: Chunk F — K7 Mig 31 Approver F2 service regression + delete deprecated Drafter F2 tests

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) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-14 23:41:26 +07:00
parent ebe2469470
commit 6b1e2d9220
2 changed files with 235 additions and 85 deletions

View File

@ -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<UserManager<User>>().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<ConflictException>()
.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");
}
}
}