[CLAUDE] Tests: Chunk M2 — Add F1 OneLevel/OneStep edge case tests Bước 1 reset ChoDuyet

Hai test mới cover edge case Plan M S23 t3 (em main M1 edit
PurchaseEvaluationWorkflowService.cs line 287-333):

- ApplyReturnMode_OneLevel_AtStep1Level1_ResetsToBuoc1Cap1_KeepsChoDuyet
  PE init Step 0 Cấp 1 + actor=a1 + slot Cấp 1 tick AllowReturnOneLevel.
  Trước fallback Drafter Phase=TraLai → sau reset (0, 1) giữ Phase=ChoDuyet,
  SLA reset 7d, audit log ContextNote chứa "không lùi được".

- ApplyReturnMode_OneStep_AtStep1_ResetsToBuoc1Cap1_KeepsChoDuyet
  PE init Step 0 Cấp 2 + actor=a2 + slot Cấp 2 tick AllowReturnOneStep.
  Service check curStepIdx > 0 → fallback ngay. Assert Phase=ChoDuyet,
  pointer (0, 1), SLA reset, audit log "không lùi được".

Extend helper SeedWorkflowAsync +2 params optional (allowReturnOneLevelL1
+ allowReturnOneStepL2) — default false, không phá compat 4 test ReturnMode
existing. Pattern 3 audit-reuse (extend helper KHÔNG clone).

Audit log assertion dùng ContextNote thay vì Summary: LogTransitionAsync set
Summary cố định "Chuyển phase {from} → {to}", summary từ ApplyReturnModeAsync
chèn vào comment qua line 96-99 service → ContextNote = comment.

K7 cascade NO regression: 3 ApproveV2_SkipToFinal_* tests still green (M1
edit chỉ F1 path, KHÔNG đụng F2 ApproveV2Async).

Verify:
- Build pass (0 error, 2 pre-existing DocxRenderer warning)
- 106/106 test pass (58 Domain + 48 Infra: +2 từ 46 baseline Plan L)
- 10 ReturnMode-class tests pass (4 ReturnMode + 3 ApproveV2_SkipToFinal
  + 1 Reject_NonApprover + 2 edge case mới)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-15 11:15:47 +07:00
parent 508b17a43c
commit 4dd6f9c013
2 changed files with 116 additions and 2 deletions

View File

@ -42,6 +42,8 @@ public class PurchaseEvaluationWorkflowServiceReturnModeTests
// Workflow setup: 1 Bước (1 Step) — 2 Cấp (2 Levels) — mỗi Cấp 1 Approver.
// Mặc định mọi Allow* = false trên Level slot (admin opt-in pattern Mig 29).
// ApproverUserId mặc định = approverId truyền vào (caller có thể override).
// Plan M S23 t3 — thêm 2 param L1 (`allowReturnOneLevelL1` + `allowReturnOneStepL2`)
// phục vụ test edge case Bước 1 Cấp 1 reset ChoDuyet (không lùi được).
private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep step, ApprovalWorkflowLevel l1, ApprovalWorkflowLevel l2)>
SeedWorkflowAsync(
TestApplicationDbContext db,
@ -49,7 +51,9 @@ public class PurchaseEvaluationWorkflowServiceReturnModeTests
Guid approver2UserId,
bool allowReturnOneLevelL2 = false,
bool allowReturnToDrafterL2 = false,
bool allowApproverEditL2 = false)
bool allowApproverEditL2 = false,
bool allowReturnOneLevelL1 = false,
bool allowReturnOneStepL2 = false)
{
var wf = new ApprovalWorkflow
{
@ -75,7 +79,8 @@ public class PurchaseEvaluationWorkflowServiceReturnModeTests
ApprovalWorkflowStepId = step.Id,
Order = 1,
ApproverUserId = approver1UserId,
// L1 defaults: Allow* = false (test sad path easier)
AllowReturnOneLevel = allowReturnOneLevelL1,
// Các Allow* khác mặc định false (sad path)
};
var l2 = new ApprovalWorkflowLevel
{
@ -84,6 +89,7 @@ public class PurchaseEvaluationWorkflowServiceReturnModeTests
Order = 2,
ApproverUserId = approver2UserId,
AllowReturnOneLevel = allowReturnOneLevelL2,
AllowReturnOneStep = allowReturnOneStepL2,
AllowReturnToDrafter = allowReturnToDrafterL2,
AllowApproverEditDetails = allowApproverEditL2,
};
@ -240,6 +246,113 @@ public class PurchaseEvaluationWorkflowServiceReturnModeTests
}
}
// ============ Plan M S23 t3 — F1 edge case Bước 1 reset ChoDuyet ============
// Cũ: Approver Bước 1 Cấp 1 click "Trả lại 1 Cấp" / "Trả lại 1 Bước" → fallback
// Drafter mode (Phase=TraLai, clear pointer). UX confusing: Approver A đột nhiên
// bị "đẩy phiếu về Drafter" mặc dù mình chính là người đang giữ phiếu.
// Mới: reset (Step=0, Level=1) GIỮ Phase=ChoDuyet — no-op effective (Approver A
// vẫn giữ phiếu), SLA reset 7d, audit log ContextNote "không lùi được". 2 test
// dưới cover OneLevel + OneStep edge case riêng — Drafter mode (line 268-275)
// GIỮ semantic Phase=TraLai unchanged.
[Fact]
public async Task ApplyReturnMode_OneLevel_AtStep1Level1_ResetsToBuoc1Cap1_KeepsChoDuyet()
{
// Workflow 1 Step × 2 Levels, PE đang Bước 1 Cấp 1 (Approver A duyệt).
// Slot Cấp 1 tick AllowReturnOneLevel=true (admin opt-in pattern Mig 29).
var (svc, fix, db, _) = CreateService();
using (fix)
{
var (a1, a2) = await SeedApproversAsync(fix, "rm-edge-onelevel");
var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id, allowReturnOneLevelL1: true);
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = PurchaseEvaluationPhase.ChoDuyet,
MaPhieu = "PE-RM-EDGE-OL",
TenGoiThau = "OneLevel edge Bước 1 Cấp 1",
ProjectId = Guid.NewGuid(),
DrafterUserId = Guid.NewGuid(),
ApprovalWorkflowId = wf.Id,
CurrentWorkflowStepIndex = 0, // Bước 1
CurrentApprovalLevelOrder = 1, // Cấp 1 — edge case "không lùi được"
};
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
await svc.TransitionAsync(
evaluation: pe,
targetPhase: PurchaseEvaluationPhase.TraLai,
actorUserId: a1.Id,
actorRoles: new[] { AppRoles.CostControl },
decision: ApprovalDecision.Reject,
comment: "thử trả 1 cấp tại Bước 1 Cấp 1",
returnMode: WorkflowReturnMode.OneLevel,
ct: CancellationToken.None);
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
"Plan M edge case: GIỮ ChoDuyet (no-op effective), KHÔNG fallback Drafter TraLai");
pe.CurrentWorkflowStepIndex.Should().Be(0, "Pointer reset Bước 1");
pe.CurrentApprovalLevelOrder.Should().Be(1, "Pointer reset Cấp 1");
pe.SlaDeadline.Should().NotBeNull("SLA reset 7d cho Approver A giữ phiếu");
// Audit log "không lùi được" phải xuất hiện ở ContextNote (LogTransitionAsync
// append summary từ ApplyReturnModeAsync vào field này — line 96-99 service).
// Summary field cố định "Chuyển phase {from} → {to}".
var changelog = await db.PurchaseEvaluationChangelogs
.Where(c => c.PurchaseEvaluationId == pe.Id)
.OrderByDescending(c => c.CreatedAt)
.FirstAsync();
changelog.ContextNote.Should().NotBeNull();
changelog.ContextNote!.Should().Contain("không lùi được",
"Audit trail rõ ràng cho UAT review — phân biệt no-op vs lùi thật");
}
}
[Fact]
public async Task ApplyReturnMode_OneStep_AtStep1_ResetsToBuoc1Cap1_KeepsChoDuyet()
{
// Workflow 1 Step × 2 Levels, PE đang Bước 1 Cấp 2 (Approver B duyệt).
// Slot Cấp 2 tick AllowReturnOneStep=true. Action OneStep từ Bước 1 → edge
// case "không lùi được Bước" (vẫn là first Step) → reset (0, 1) giữ ChoDuyet.
// Approver B sau reset bị bàn giao về Approver A (Cấp 1) — vẫn trong chuỗi duyệt.
var (svc, fix, db, _) = CreateService();
using (fix)
{
var (a1, a2) = await SeedApproversAsync(fix, "rm-edge-onestep");
var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id, allowReturnOneStepL2: true);
var pe = BuildPeAtLevel2(wf.Id, drafterId: Guid.NewGuid(), code: "PE-RM-EDGE-OS");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
await svc.TransitionAsync(
evaluation: pe,
targetPhase: PurchaseEvaluationPhase.TraLai,
actorUserId: a2.Id,
actorRoles: new[] { AppRoles.CostControl },
decision: ApprovalDecision.Reject,
comment: "thử trả 1 bước tại Bước 1",
returnMode: WorkflowReturnMode.OneStep,
ct: CancellationToken.None);
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
"Plan M edge case: GIỮ ChoDuyet (no-op effective), KHÔNG fallback Drafter TraLai");
pe.CurrentWorkflowStepIndex.Should().Be(0, "Pointer reset Bước 1");
pe.CurrentApprovalLevelOrder.Should().Be(1, "Pointer reset Cấp 1 (bàn giao về Approver A)");
pe.SlaDeadline.Should().NotBeNull("SLA reset 7d cho approver mới");
var changelog = await db.PurchaseEvaluationChangelogs
.Where(c => c.PurchaseEvaluationId == pe.Id)
.OrderByDescending(c => c.CreatedAt)
.FirstAsync();
changelog.ContextNote.Should().NotBeNull();
changelog.ContextNote!.Should().Contain("không lùi được",
"Audit trail rõ ràng cho UAT review — phân biệt no-op vs lùi thật");
}
}
// ============ 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).