[CLAUDE] PurchaseEvaluation: A1 người-tạo ký ô cấp mình + EDGE-5 khóa terminal + opinion-history
A1: ApplyDrafterBypassOnSubmitAsync bypass CHỈ NV cấp dưới creator (order<minOwn), creator ký tay ô mình qua ApproveV2Async (bỏ auto-ký-hộ), giữ skip-NV S60. EDGE-5: guard đầu TransitionAsync chặn non-admin transition từ DaDuyet/TuChoi (admin+system exempt). History: log ý-kiến cũ→PurchaseEvaluationChangelog trước ghi đè (no migration). Tests: 4 bypass→A1 + 8 mới (happy/non-contiguous/EDGE-5/history), 458 PASS. UAT: phiếu 1-bước creator-là-cấp-cuối nay cần bấm Duyệt (bỏ auto-terminal-on-submit, đúng bám-quy-trình). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.PurchaseEvaluations.Services; // WorkflowReturnMode
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE)
|
||||
using SolutionErp.Domain.Identity;
|
||||
@ -26,11 +27,27 @@ namespace SolutionErp.Infrastructure.Tests.Services;
|
||||
// Message gộp MỌI mục thiếu 1 lần. Áp CẢ Admin (data-quality ≠ authz).
|
||||
//
|
||||
// Feature 2 — Drafter-in-chain bypass khi submit (ApplyDrafterBypassOnSubmitAsync,
|
||||
// V2-only, line 528-638 prod): drafter là approver cấp k (MAX Order match) BƯỚC
|
||||
// ĐẦU → auto qua Cấp 1..k. Approval rows per cấp (Decision=AutoApprove); opinion
|
||||
// CHỈ ghi slot chính chủ (ownSlot.ApproverUserId==drafter); pointer k<max →
|
||||
// Level=nextOrder · k==max+còn step → StepIdx=1/Level=1 · 1-step+k==max →
|
||||
// terminal DaDuyet pointers null SlaDeadline null.
|
||||
// V2-only). [A1 — close-review wf_7df56b89-7bb, owner anh Kiệt] ĐỔI hành vi cũ (auto qua
|
||||
// Cấp 1..k + auto-sign slot chính chủ + auto nhảy Bước / terminal). NAY: người soạn
|
||||
// (DrafterUserId — KHÔNG phải actor gửi; Admin gửi hộ vẫn tính theo người soạn) KÝ TAY ô
|
||||
// cấp mình. CHỈ bypass các cấp NV DƯỚI THUẦN (Order < minOwn, minOwn = cấp THẤP NHẤT người
|
||||
// soạn có slot ở BƯỚC ĐẦU). Pointer DỪNG tại minOwn → người soạn tự ký Cấp minOwn qua
|
||||
// ApproveV2Async (KHÔNG auto-sign opinion chính chủ, KHÔNG auto nhảy Bước sau, KHÔNG
|
||||
// auto-terminal DaDuyet khi gửi). Mỗi cấp bypass → 1 Approval row (Decision=AutoApprove);
|
||||
// opinion Section 5 = 0 NGAY SAU submit (chỉ sinh khi người soạn ký THẬT). minOwn==1 (người
|
||||
// soạn là Cấp 1, hoặc non-contiguous Cấp 1 + Cấp 3): bypass RỖNG → toàn bộ chuỗi ký tay
|
||||
// đúng thứ tự (KHÔNG auto qua Cấp 2 của người khác).
|
||||
//
|
||||
// [EDGE-5 — close-review wf_7df56b89-7bb, owner anh Kiệt oQ2] TransitionAsync top-guard
|
||||
// (:57-71 prod): phiếu ĐÃ KẾT THÚC (DaDuyet / TuChoi) → user THƯỜNG (non-admin) KHÔNG được
|
||||
// trả lại / từ chối / chuyển trạng thái (ConflictException "...trạng thái kết thúc..."). Chặn
|
||||
// exploit drafter forge Reject lật phiếu DaDuyet của mình về "đang duyệt". ADMIN + SYSTEM
|
||||
// (actorUserId null + decision=AutoApprove) MIỄN (un-terminal hợp lệ + cron/SLA auto-job).
|
||||
//
|
||||
// [Opinion history — close-review CHANGE 4] ApproveV2Async: re-sign ĐÈ 1 opinion CŨ non-empty
|
||||
// → ghi 1 PurchaseEvaluationChangelog (EntityType=Workflow, Action=Update, EntityId=level.Id,
|
||||
// ContextNote chứa comment cũ) TRƯỚC khi overwrite (bảo toàn lịch sử, no migration). Lần ký
|
||||
// ĐẦU (existingOpinion null) HOẶC đè opinion rỗng/whitespace → KHÔNG ghi history row.
|
||||
//
|
||||
// LƯU Ý GUARD-FIRST: submit guard chạy TRƯỚC bypass → mọi test bypass phải dựng
|
||||
// PE ĐỦ 4 điều kiện Section 3 (winner + quote>0 + manual budget + comparison file).
|
||||
@ -187,6 +204,42 @@ public class PeSubmitGuardAndBypassTests
|
||||
comment: null,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
// Approver ký Cấp hiện tại (advance pointer trong ChoDuyet qua ApproveV2Async).
|
||||
// approvedPrice* CHỈ cần khi cú ký NÀY chốt phiếu (Cấp cuối Bước cuối → DaDuyet):
|
||||
// người duyệt cuối BẮT BUỘC chọn 1 giá (ApplyApprovedPriceOnFinalize). Ký cấp
|
||||
// trung gian → để null. Match approver V2 = theo ApproverUserId (role không xét
|
||||
// trừ Admin) → default roles [Drafter] vô hại.
|
||||
private static Task ApproveAsync(
|
||||
PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId,
|
||||
string? comment = null,
|
||||
decimal? approvedPriceAmount = null,
|
||||
string? approvedPriceSource = null,
|
||||
string[]? roles = null) =>
|
||||
svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.ChoDuyet,
|
||||
actorUserId: actorUserId,
|
||||
actorRoles: roles ?? new[] { AppRoles.Drafter },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: comment,
|
||||
approvedPriceAmount: approvedPriceAmount,
|
||||
approvedPriceSource: approvedPriceSource,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
// Approver Cấp hiện tại Trả lại về Người soạn (mode Drafter — Phase=TraLai, clear
|
||||
// pointer). AllowReturnToDrafter mặc định TRUE trên Level nên không cần set flag.
|
||||
private static Task ReturnToDrafterAsync(
|
||||
PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId) =>
|
||||
svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.TraLai,
|
||||
actorUserId: actorUserId,
|
||||
actorRoles: Array.Empty<string>(),
|
||||
decision: ApprovalDecision.Reject,
|
||||
comment: "Trả lại để người soạn sửa",
|
||||
returnMode: WorkflowReturnMode.Drafter,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
// =====================================================================
|
||||
// FEATURE 1 — Section 3 completeness guard
|
||||
// =====================================================================
|
||||
@ -462,12 +515,13 @@ public class PeSubmitGuardAndBypassTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_DrafterIsTopLevelOfFirstStep_BypassesAllLevels_MovesToStep2()
|
||||
public async Task Submit_DrafterIsTopLevelOfFirstStep_BypassesSubordinatesOnly_StopsAtOwnLevel()
|
||||
{
|
||||
// (9) Drafter = TP (cấp 2/2 bước 1), workflow 2 bước (bước 1 có Cấp 1=NV +
|
||||
// Cấp 2=TP-drafter; bước 2 có 1 cấp = sếp). k=2=maxLevel bước 1 + còn bước 2
|
||||
// → pointer StepIdx=1 Level=1. Opinion CHỈ 1 row slot TP (Cấp 2). 2 approval
|
||||
// AutoApprove (Cấp 1 + Cấp 2). KHÔNG opinion Cấp 1.
|
||||
// (9) [A1] Drafter = TP (cấp 2/2 bước 1), workflow 2 bước (bước 1 có Cấp 1=NV +
|
||||
// Cấp 2=TP-drafter; bước 2 có 1 cấp = sếp). minOwn=2 → CHỈ bypass Cấp 1 (NV dưới
|
||||
// thuần). Pointer DỪNG tại (StepIdx=0, Level=2) — KHÔNG advance sang Bước 2; người
|
||||
// soạn TP phải tự ký Cấp 2 qua ApproveV2Async sau đó. Opinion count=0 NGAY SAU submit
|
||||
// (KHÔNG auto-sign slot chính chủ). 1 approval AutoApprove (chỉ Cấp 1 bypass).
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
@ -486,32 +540,28 @@ public class PeSubmitGuardAndBypassTests
|
||||
await SubmitAsync(svc, pe, drafterTp);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(1, "k=max bước 1 + còn bước 2 → sang Bước 2");
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(0, "[A1] DỪNG tại bước đầu — người soạn ký Cấp mình, KHÔNG nhảy Bước 2");
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(2, "[A1] pointer dừng tại minOwn=2 (cấp thấp nhất của người soạn)");
|
||||
|
||||
// Opinion CHỈ slot chính chủ (TP Cấp 2). KHÔNG opinion Cấp 1 (NV bị skip).
|
||||
// [A1] KHÔNG auto-sign opinion chính chủ → 0 opinion sau submit.
|
||||
var opinions = await db.PurchaseEvaluationLevelOpinions
|
||||
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||
opinions.Should().HaveCount(1, "chỉ ghi opinion slot drafter, không ghi hộ NV skip");
|
||||
var tpLevelId = wf.Steps.First(s => s.Order == 1).Levels.First(l => l.Order == 2).Id;
|
||||
opinions[0].ApprovalWorkflowLevelId.Should().Be(tpLevelId);
|
||||
opinions[0].SignedByUserId.Should().Be(drafterTp);
|
||||
opinions[0].Comment.Should().Contain("duyệt tự động");
|
||||
opinions.Should().BeEmpty("[A1] KHÔNG tự ký hộ opinion — người soạn ký tay Cấp 2 sau");
|
||||
|
||||
// 2 approval AutoApprove (Cấp 1 + Cấp 2 bước 1).
|
||||
// Chỉ Cấp 1 (NV dưới thuần) bypass → 1 approval AutoApprove.
|
||||
var autoApprovals = await db.PurchaseEvaluationApprovals
|
||||
.Where(a => a.PurchaseEvaluationId == pe.Id
|
||||
&& a.Decision == ApprovalDecision.AutoApprove).ToListAsync();
|
||||
autoApprovals.Should().HaveCount(2, "2 cấp bị bypass = 2 vết Approval AutoApprove");
|
||||
autoApprovals.Should().HaveCount(1, "[A1] chỉ bypass Cấp 1 NV dưới = 1 vết AutoApprove");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_DrafterIsLevel1OfFirstStep_BypassesLevel1Only_MovesToLevel2SameStep()
|
||||
public async Task Submit_DrafterIsLevel1OfFirstStep_BypassesNothing_StaysAtLevel1()
|
||||
{
|
||||
// (10) Drafter = NV cấp 1/2 bước 1 (workflow 1 bước có 2 cấp). k=1 < maxLevel=2
|
||||
// → pointer Level=2 cùng bước (StepIdx giữ 0). Opinion slot NV Cấp 1. 1 approval
|
||||
// AutoApprove. Cấp 2 KHÔNG bypass (approver khác).
|
||||
// (10) [A1] Drafter = NV cấp 1/2 bước 1 (workflow 1 bước có 2 cấp). minOwn=1 →
|
||||
// KHÔNG có cấp nào Order < 1 → bypass RỖNG. Pointer giữ init (StepIdx=0, Level=1) —
|
||||
// người soạn tự ký Cấp 1 qua ApproveV2Async. Opinion=0, AutoApprove=0 sau submit.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
@ -525,19 +575,16 @@ public class PeSubmitGuardAndBypassTests
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(0, "cùng bước");
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(2, "k=1 < max=2 → chờ Cấp 2");
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1, "[A1] minOwn=1 → bypass rỗng → giữ Cấp 1 (người soạn tự ký)");
|
||||
|
||||
var opinions = await db.PurchaseEvaluationLevelOpinions
|
||||
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||
opinions.Should().HaveCount(1);
|
||||
var nvLevelId = wf.Steps.First().Levels.First(l => l.Order == 1).Id;
|
||||
opinions[0].ApprovalWorkflowLevelId.Should().Be(nvLevelId, "slot NV Cấp 1 chính chủ");
|
||||
opinions[0].SignedByUserId.Should().Be(drafterNv);
|
||||
opinions.Should().BeEmpty("[A1] người soạn Cấp 1 → không bypass, không auto-sign");
|
||||
|
||||
var autoApprovals = await db.PurchaseEvaluationApprovals
|
||||
.Where(a => a.PurchaseEvaluationId == pe.Id
|
||||
&& a.Decision == ApprovalDecision.AutoApprove).ToListAsync();
|
||||
autoApprovals.Should().HaveCount(1, "chỉ Cấp 1 bypass");
|
||||
autoApprovals.Should().BeEmpty("[A1] 0 cấp dưới → 0 bypass");
|
||||
}
|
||||
}
|
||||
|
||||
@ -577,10 +624,12 @@ public class PeSubmitGuardAndBypassTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_OneStepWorkflow_DrafterIsLastLevel_TerminalDaDuyet_PointersNull()
|
||||
public async Task Submit_OneStepWorkflow_DrafterIsLastLevel_StopsAtOwnLevel_ThenSignsToDaDuyet()
|
||||
{
|
||||
// (12) Workflow 1 bước (2 cấp) + drafter = cấp cuối (Cấp 2). k=2=maxLevel +
|
||||
// chỉ 1 bước → terminal DaDuyet, pointers null, SlaDeadline null.
|
||||
// (12) [A1] Workflow 1 bước (2 cấp) + drafter = cấp cuối (Cấp 2). minOwn=2 → bypass
|
||||
// Cấp 1 (NV dưới) NHƯNG KHÔNG auto-terminal khi gửi: pointer DỪNG tại (0, 2) với
|
||||
// Phase=ChoDuyet. Người soạn phải TỰ KÝ Cấp 2 qua ApproveV2Async để "bám đúng quy
|
||||
// trình" → chỉ khi đó mới DaDuyet. Chốt: submit KHÔNG còn tự-duyệt-hộ phiếu của mình.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
@ -590,24 +639,38 @@ public class PeSubmitGuardAndBypassTests
|
||||
var wf = await SeedWorkflowSingleStepAsync(db, level1Nv, drafterLast); // Cấp1=NV, Cấp2=drafter(cuối)
|
||||
var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterLast, "PE-S60-012");
|
||||
|
||||
// --- Bước 1: submit → dừng tại Cấp 2, CHƯA terminal ---
|
||||
await SubmitAsync(svc, pe, drafterLast);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "1 bước + drafter cấp cuối → terminal");
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "[A1] submit KHÔNG auto-terminal — dừng chờ người soạn ký");
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(0);
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(2, "[A1] dừng tại minOwn=2");
|
||||
|
||||
(await db.PurchaseEvaluationLevelOpinions
|
||||
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync())
|
||||
.Should().BeEmpty("[A1] chưa ký → chưa có opinion");
|
||||
|
||||
(await db.PurchaseEvaluationApprovals
|
||||
.Where(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove).ToListAsync())
|
||||
.Should().HaveCount(1, "[A1] chỉ Cấp 1 NV dưới bypass");
|
||||
|
||||
// --- Bước 2: người soạn TỰ KÝ Cấp 2 (cấp cuối bước cuối) → chốt giá → DaDuyet ---
|
||||
await ApproveAsync(svc, pe, drafterLast, comment: "Người soạn ký chốt Cấp 2",
|
||||
approvedPriceAmount: 1_500_000m, approvedPriceSource: "Ncc");
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "người soạn ký tay Cấp cuối → terminal");
|
||||
pe.CurrentWorkflowStepIndex.Should().BeNull();
|
||||
pe.CurrentApprovalLevelOrder.Should().BeNull();
|
||||
pe.SlaDeadline.Should().BeNull();
|
||||
|
||||
// Opinion CHỈ slot drafter (Cấp 2). KHÔNG opinion Cấp 1.
|
||||
// Opinion CHỈ slot drafter (Cấp 2) — sinh khi ký THẬT, comment thật (không canned).
|
||||
var opinions = await db.PurchaseEvaluationLevelOpinions
|
||||
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||
opinions.Should().HaveCount(1);
|
||||
opinions.Should().HaveCount(1, "[A1] chỉ 1 opinion do người soạn ký tay Cấp 2");
|
||||
var drafterLevelId = wf.Steps.First().Levels.First(l => l.Order == 2).Id;
|
||||
opinions[0].ApprovalWorkflowLevelId.Should().Be(drafterLevelId);
|
||||
|
||||
var autoApprovals = await db.PurchaseEvaluationApprovals
|
||||
.Where(a => a.PurchaseEvaluationId == pe.Id
|
||||
&& a.Decision == ApprovalDecision.AutoApprove).ToListAsync();
|
||||
autoApprovals.Should().HaveCount(2, "Cấp 1 + Cấp 2 bypass");
|
||||
opinions[0].SignedByUserId.Should().Be(drafterLast);
|
||||
opinions[0].Comment.Should().Be("Người soạn ký chốt Cấp 2");
|
||||
}
|
||||
}
|
||||
|
||||
@ -640,11 +703,13 @@ public class PeSubmitGuardAndBypassTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resubmit_FromTraLai_ReAppliesBypass_OpinionNotDuplicated_ApprovalAccumulates()
|
||||
public async Task Resubmit_FromTraLai_ReRunsBypass_DrafterSoleLevel_NoBypassNoAutoTerminal()
|
||||
{
|
||||
// (14) TraLai → resubmit → bypass áp lại. Opinion KHÔNG duplicate (UPSERT 1
|
||||
// row). Approval rows cộng dồn vết (2 lần gửi = 2× AutoApprove cùng cấp).
|
||||
// Dùng workflow 1 bước 1 cấp = drafter → mỗi submit terminal DaDuyet.
|
||||
// (14) [A1] Workflow 1 bước 1 cấp = drafter (người soạn LÀ cấp duy nhất). minOwn=1 →
|
||||
// bypass RỖNG. ĐỔI hành vi cũ (auto-terminal DaDuyet + auto-sign khi gửi): NAY submit
|
||||
// chỉ đưa phiếu tới ChoDuyet tại (0, 1), opinion=0, AutoApprove=0 — người soạn phải tự
|
||||
// ký. Resubmit từ TraLai chạy LẠI bypass idempotent: vẫn 0 bypass row, KHÔNG cộng dồn,
|
||||
// KHÔNG duplicate (vì không có gì để cộng/nhân bản khi bypass rỗng).
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
@ -652,16 +717,18 @@ public class PeSubmitGuardAndBypassTests
|
||||
var wf = await SeedWorkflowSingleStepAsync(db, drafterSolo); // 1 bước, 1 cấp = drafter
|
||||
var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterSolo, "PE-S60-014");
|
||||
|
||||
// Lần gửi 1 → terminal DaDuyet, 1 opinion + 1 AutoApprove.
|
||||
// Lần gửi 1 → ChoDuyet (0,1), KHÔNG terminal, 0 opinion + 0 AutoApprove.
|
||||
await SubmitAsync(svc, pe, drafterSolo);
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet);
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "[A1] auto-terminal-on-submit đã bỏ");
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(0);
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1, "[A1] người soạn Cấp 1 → giữ chờ ký");
|
||||
|
||||
var opinionsAfter1 = await db.PurchaseEvaluationLevelOpinions
|
||||
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||
opinionsAfter1.Should().HaveCount(1);
|
||||
var autoAfter1 = await db.PurchaseEvaluationApprovals
|
||||
.CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove);
|
||||
autoAfter1.Should().Be(1);
|
||||
(await db.PurchaseEvaluationLevelOpinions
|
||||
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync())
|
||||
.Should().BeEmpty("[A1] chưa ký → 0 opinion");
|
||||
(await db.PurchaseEvaluationApprovals
|
||||
.CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove))
|
||||
.Should().Be(0, "[A1] minOwn=1 → bypass rỗng");
|
||||
|
||||
// Mô phỏng Trả lại: reset về TraLai (như Reject branch Drafter mode làm).
|
||||
pe.Phase = PurchaseEvaluationPhase.TraLai;
|
||||
@ -670,17 +737,328 @@ public class PeSubmitGuardAndBypassTests
|
||||
pe.SlaDeadline = null;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// Lần gửi 2 (resubmit từ TraLai) → bypass áp lại.
|
||||
// Lần gửi 2 (resubmit từ TraLai) → bypass chạy lại nhưng vẫn RỖNG (idempotent).
|
||||
await SubmitAsync(svc, pe, drafterSolo);
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "resubmit áp lại bypass → terminal");
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "[A1] resubmit → ChoDuyet (0,1), vẫn KHÔNG terminal");
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(0);
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||
|
||||
var opinionsAfter2 = await db.PurchaseEvaluationLevelOpinions
|
||||
(await db.PurchaseEvaluationLevelOpinions
|
||||
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync())
|
||||
.Should().BeEmpty("[A1] vẫn 0 opinion sau resubmit — không có auto-sign");
|
||||
(await db.PurchaseEvaluationApprovals
|
||||
.CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove))
|
||||
.Should().Be(0, "[A1] bypass rỗng cả 2 lần gửi → KHÔNG cộng dồn AutoApprove");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// A1 NEW COVERAGE — happy-path người soạn ký tay + non-contiguous manual chain
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_DrafterAtLevel2_ThenSignsOwnLevelWithRealComment_OpinionIsRealNotCanned()
|
||||
{
|
||||
// [A1 happy-path] Workflow 2 bước (Bước 1: Cấp 1=NV dưới, Cấp 2=drafter · Bước 2:
|
||||
// Cấp 1=boss). Submit → bypass Cấp 1, pointer DỪNG (0, 2). Người soạn TỰ KÝ Cấp 2
|
||||
// qua ApproveV2Async với ý kiến THẬT → advance sang Bước 2. Opinion phải là comment
|
||||
// THẬT của người soạn + SignedByUserId=drafter, TUYỆT ĐỐI KHÔNG phải canned
|
||||
// "(duyệt tự động…)" (chuỗi này đã bị gỡ khỏi prod trong A1 — không còn auto-sign).
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var nvSub = (await fix.CreateUserAsync("nvsub@s60.test", "NV Duoi", null, Array.Empty<string>())).Id;
|
||||
var drafter = (await fix.CreateUserAsync("drafter2@s60.test", "TP Drafter", null, new[] { AppRoles.Drafter })).Id;
|
||||
var boss = (await fix.CreateUserAsync("bossb@s60.test", "Boss Buoc 2", null, Array.Empty<string>())).Id;
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, new[]
|
||||
{
|
||||
new[] { nvSub, drafter }, // Bước 1: Cấp 1 = NV dưới, Cấp 2 = drafter
|
||||
new[] { boss }, // Bước 2: Cấp 1 = boss
|
||||
});
|
||||
var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafter, "PE-A1-HAPPY");
|
||||
|
||||
await SubmitAsync(svc, pe, drafter);
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(2, "bypass Cấp 1 → dừng tại Cấp 2 người soạn");
|
||||
(await db.PurchaseEvaluationLevelOpinions.CountAsync(o => o.PurchaseEvaluationId == pe.Id))
|
||||
.Should().Be(0, "chưa ký → 0 opinion");
|
||||
|
||||
const string realComment = "Tôi đồng ý chọn nhà cung cấp này — giá hợp lý";
|
||||
await ApproveAsync(svc, pe, drafter, comment: realComment);
|
||||
|
||||
// Advance sang Bước 2 (KHÔNG terminal — còn boss).
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(1, "ký xong Cấp 2 Bước 1 → sang Bước 2");
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||
|
||||
var opinions = await db.PurchaseEvaluationLevelOpinions
|
||||
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||
opinionsAfter2.Should().HaveCount(1, "UPSERT — opinion KHÔNG duplicate sau resubmit");
|
||||
opinions.Should().HaveCount(1, "chỉ 1 opinion — do người soạn ký tay (Cấp 1 bypass KHÔNG có opinion)");
|
||||
var drafterLevelId = wf.Steps.First(s => s.Order == 1).Levels.First(l => l.Order == 2).Id;
|
||||
opinions[0].ApprovalWorkflowLevelId.Should().Be(drafterLevelId);
|
||||
opinions[0].SignedByUserId.Should().Be(drafter, "người soạn ký THẬT — không phải ký hộ");
|
||||
opinions[0].Comment.Should().Be(realComment, "opinion = ý kiến thật của người soạn");
|
||||
opinions[0].Comment.Should().NotContain("duyệt tự động", "A1 gỡ canned auto-sign");
|
||||
opinions[0].Comment.Should().NotContain("bỏ qua");
|
||||
}
|
||||
}
|
||||
|
||||
var autoAfter2 = await db.PurchaseEvaluationApprovals
|
||||
.CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove);
|
||||
autoAfter2.Should().Be(2, "approval rows cộng dồn — 2 lần gửi = 2 vết AutoApprove");
|
||||
[Fact]
|
||||
public async Task Submit_DrafterNonContiguousLevel1And3_NoBypass_FullManualChainInOrder()
|
||||
{
|
||||
// [A1 non-contiguous] Bước 1 có 3 cấp: Cấp 1=drafter, Cấp 2=NV khác, Cấp 3=drafter.
|
||||
// minOwn=1 → bypass RỖNG (KHÔNG auto qua Cấp 2 của người khác). Toàn bộ chuỗi ký TAY
|
||||
// đúng thứ tự: drafter ký Cấp 1 → NV khác ký Cấp 2 → drafter ký Cấp 3 (chốt giá →
|
||||
// DaDuyet). 3 opinion đúng người ký, 0 AutoApprove, 3 Approve.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var drafter = (await fix.CreateUserAsync("dnc@s60.test", "Drafter NC", null, new[] { AppRoles.Drafter })).Id;
|
||||
var otherNv = (await fix.CreateUserAsync("otherc2@s60.test", "NV Cap 2 khac", null, Array.Empty<string>())).Id;
|
||||
|
||||
// 1 bước, 3 cấp: Cấp1=drafter, Cấp2=otherNv, Cấp3=drafter (non-contiguous).
|
||||
var wf = await SeedWorkflowSingleStepAsync(db, drafter, otherNv, drafter);
|
||||
var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafter, "PE-A1-NONCONTIG");
|
||||
|
||||
await SubmitAsync(svc, pe, drafter);
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(0);
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1, "minOwn=1 → bypass rỗng → bắt đầu Cấp 1");
|
||||
(await db.PurchaseEvaluationApprovals
|
||||
.CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove))
|
||||
.Should().Be(0, "non-contiguous minOwn=1 → KHÔNG auto qua Cấp 2 người khác");
|
||||
|
||||
// Chuỗi ký tay đúng thứ tự.
|
||||
await ApproveAsync(svc, pe, drafter, comment: "Cấp 1 — người soạn");
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(2, "sang Cấp 2");
|
||||
|
||||
await ApproveAsync(svc, pe, otherNv, comment: "Cấp 2 — NV khác", roles: Array.Empty<string>());
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(3, "sang Cấp 3");
|
||||
|
||||
await ApproveAsync(svc, pe, drafter, comment: "Cấp 3 — người soạn chốt",
|
||||
approvedPriceAmount: 1_500_000m, approvedPriceSource: "Ncc");
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "ký hết 3 cấp → terminal");
|
||||
|
||||
// Map level Id → Order từ wf in-memory (đã có Id sau SaveChanges) — tránh Join SQLite.
|
||||
var levelByOrder = wf.Steps.First().Levels.ToDictionary(l => l.Order, l => l.Id);
|
||||
var opinions = await db.PurchaseEvaluationLevelOpinions
|
||||
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||
opinions.Should().HaveCount(3, "cả 3 cấp ký tay → 3 opinion");
|
||||
opinions.Single(o => o.ApprovalWorkflowLevelId == levelByOrder[1]).SignedByUserId
|
||||
.Should().Be(drafter, "Cấp 1 do người soạn ký");
|
||||
opinions.Single(o => o.ApprovalWorkflowLevelId == levelByOrder[2]).SignedByUserId
|
||||
.Should().Be(otherNv, "Cấp 2 do NV khác ký");
|
||||
opinions.Single(o => o.ApprovalWorkflowLevelId == levelByOrder[3]).SignedByUserId
|
||||
.Should().Be(drafter, "Cấp 3 do người soạn ký");
|
||||
opinions.Should().OnlyContain(o => !o.Comment!.Contains("bỏ qua"), "không có auto-sign hộ");
|
||||
|
||||
(await db.PurchaseEvaluationApprovals
|
||||
.CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove))
|
||||
.Should().Be(0, "toàn bộ chuỗi ký tay → 0 AutoApprove");
|
||||
(await db.PurchaseEvaluationApprovals
|
||||
.CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.Approve))
|
||||
.Should().Be(3, "3 lượt ký tay = 3 Approve");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// EDGE-5 (security) — top-guard chặn non-admin đổi trạng thái phiếu ĐÃ KẾT THÚC
|
||||
// =====================================================================
|
||||
|
||||
// Dựng phiếu ở phase kết thúc cho test EDGE-5 (không cần workflow — guard đứng TRƯỚC).
|
||||
private static async Task<PurchaseEvaluation> BuildTerminalPeAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluationPhase terminalPhase, string code)
|
||||
{
|
||||
var pe = BuildPeNhap(code: code);
|
||||
pe.Phase = terminalPhase;
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return pe;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transition_NonAdminReject_FromDaDuyet_ThrowsTerminalLock_Edge5()
|
||||
{
|
||||
// [EDGE-5] non-admin forge Reject (target=TraLai) trên phiếu DaDuyet → ConflictException
|
||||
// "trạng thái kết thúc". Chặn lật phiếu đã duyệt về "đang duyệt". Phiếu giữ DaDuyet.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var pe = await BuildTerminalPeAsync(db, PurchaseEvaluationPhase.DaDuyet, "PE-EDGE5-DADUYET");
|
||||
|
||||
var act = () => svc.TransitionAsync(pe, PurchaseEvaluationPhase.TraLai,
|
||||
actorUserId: Guid.NewGuid(), actorRoles: new[] { AppRoles.Drafter },
|
||||
decision: ApprovalDecision.Reject, comment: "forge trả lại", ct: CancellationToken.None);
|
||||
|
||||
var ex = await act.Should().ThrowAsync<ConflictException>();
|
||||
ex.Which.Message.Should().Contain("trạng thái kết thúc");
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "guard chặn TRƯỚC mutate — phiếu giữ DaDuyet");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transition_NonAdminReject_FromTuChoi_ThrowsTerminalLock_Edge5()
|
||||
{
|
||||
// [EDGE-5] Tương tự từ TuChoi (phase kết thúc thứ 2) → cùng ConflictException.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var pe = await BuildTerminalPeAsync(db, PurchaseEvaluationPhase.TuChoi, "PE-EDGE5-TUCHOI");
|
||||
|
||||
var act = () => svc.TransitionAsync(pe, PurchaseEvaluationPhase.TraLai,
|
||||
actorUserId: Guid.NewGuid(), actorRoles: new[] { AppRoles.Drafter },
|
||||
decision: ApprovalDecision.Reject, comment: "forge trả lại", ct: CancellationToken.None);
|
||||
|
||||
var ex = await act.Should().ThrowAsync<ConflictException>();
|
||||
ex.Which.Message.Should().Contain("trạng thái kết thúc");
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.TuChoi);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transition_AdminReject_FromDaDuyet_NotBlockedByTerminalLock_Edge5()
|
||||
{
|
||||
// [EDGE-5] ADMIN MIỄN guard → qua top-guard, un-terminal phiếu DaDuyet về TraLai
|
||||
// (owner oQ2 "Admin đầy đủ nhất"). KHÔNG ném "trạng thái kết thúc".
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var pe = await BuildTerminalPeAsync(db, PurchaseEvaluationPhase.DaDuyet, "PE-EDGE5-ADMIN");
|
||||
|
||||
var act = () => svc.TransitionAsync(pe, PurchaseEvaluationPhase.TraLai,
|
||||
actorUserId: Guid.NewGuid(), actorRoles: new[] { AppRoles.Admin },
|
||||
decision: ApprovalDecision.Reject, comment: "admin un-terminal",
|
||||
returnMode: WorkflowReturnMode.Drafter, ct: CancellationToken.None);
|
||||
|
||||
await act.Should().NotThrowAsync("Admin được MIỄN EDGE-5 guard");
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.TraLai, "Admin qua guard + un-terminal thành công");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transition_SystemAutoApprove_FromDaDuyet_NotBlockedByTerminalLock_Edge5()
|
||||
{
|
||||
// [EDGE-5] SYSTEM (actorUserId=null + decision=AutoApprove, vd cron/SLA) MIỄN guard.
|
||||
// Từ DaDuyet không có transition system hợp lệ → rơi xuống fall-through "không hỗ trợ"
|
||||
// (KHÁC "trạng thái kết thúc") → chứng minh đã QUA EDGE-5, không bị guard này chặn.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var pe = await BuildTerminalPeAsync(db, PurchaseEvaluationPhase.DaDuyet, "PE-EDGE5-SYSTEM");
|
||||
|
||||
var act = () => svc.TransitionAsync(pe, PurchaseEvaluationPhase.ChoDuyet,
|
||||
actorUserId: null, actorRoles: Array.Empty<string>(),
|
||||
decision: ApprovalDecision.AutoApprove, comment: "cron", ct: CancellationToken.None);
|
||||
|
||||
var ex = await act.Should().ThrowAsync<ConflictException>();
|
||||
ex.Which.Message.Should().NotContain("trạng thái kết thúc", "system MIỄN EDGE-5 guard");
|
||||
ex.Which.Message.Should().Contain("không hỗ trợ", "qua EDGE-5 → fall-through vì DaDuyet không có transition system");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Opinion history (CHANGE 4) — re-sign đè opinion cũ → 1 Changelog Workflow/Update
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task ReSign_OverExistingNonEmptyOpinion_LogsExactlyOneHistoryRow_FirstSignLogsNone()
|
||||
{
|
||||
// [CHANGE 4] Workflow 1 bước 2 cấp (Cấp 1=approverA, Cấp 2=approverB). Drafter ngoài
|
||||
// chuỗi → không bypass. approverA ký Cấp 1 (lần ĐẦU) → KHÔNG history row. approverB
|
||||
// Trả lại → drafter resubmit → approverA ký Cấp 1 LẦN 2 → đúng 1 Changelog
|
||||
// (EntityType=Workflow, Action=Update, EntityId=Cấp1.Id, ContextNote chứa comment cũ)
|
||||
// + opinion.Comment cập nhật bản mới.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var approverA = (await fix.CreateUserAsync("appA@s60.test", "Approver A", null, Array.Empty<string>())).Id;
|
||||
var approverB = (await fix.CreateUserAsync("appB@s60.test", "Approver B", null, Array.Empty<string>())).Id;
|
||||
var drafterX = (await fix.CreateUserAsync("dx@s60.test", "Drafter Ngoai", null, new[] { AppRoles.Drafter })).Id;
|
||||
|
||||
var wf = await SeedWorkflowSingleStepAsync(db, approverA, approverB); // Cấp1=A, Cấp2=B
|
||||
var cap1Id = wf.Steps.First().Levels.First(l => l.Order == 1).Id;
|
||||
var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterX, "PE-HIST-1");
|
||||
|
||||
await SubmitAsync(svc, pe, drafterX); // drafter ngoài chuỗi → pointer (0,1), no bypass
|
||||
|
||||
const string round1 = "Round 1: đồng ý sơ bộ";
|
||||
await ApproveAsync(svc, pe, approverA, comment: round1, roles: Array.Empty<string>());
|
||||
|
||||
// Lần ký ĐẦU → KHÔNG có history row cho Cấp 1.
|
||||
(await db.PurchaseEvaluationChangelogs.CountAsync(c =>
|
||||
c.PurchaseEvaluationId == pe.Id
|
||||
&& c.EntityId == cap1Id
|
||||
&& c.Action == ChangelogAction.Update))
|
||||
.Should().Be(0, "[CHANGE 4] lần ký đầu (existingOpinion null) → KHÔNG log history");
|
||||
|
||||
// approverB Trả lại về người soạn → resubmit → về lại Cấp 1.
|
||||
await ReturnToDrafterAsync(svc, pe, approverB);
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.TraLai);
|
||||
await SubmitAsync(svc, pe, drafterX);
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1, "resubmit → chạy lại từ Cấp 1");
|
||||
|
||||
// approverA ký Cấp 1 LẦN 2 (đè opinion cũ non-empty).
|
||||
const string round2 = "Round 2: đã xem lại, vẫn đồng ý";
|
||||
await ApproveAsync(svc, pe, approverA, comment: round2, roles: Array.Empty<string>());
|
||||
|
||||
var histRows = await db.PurchaseEvaluationChangelogs
|
||||
.Where(c => c.PurchaseEvaluationId == pe.Id
|
||||
&& c.EntityId == cap1Id
|
||||
&& c.Action == ChangelogAction.Update)
|
||||
.ToListAsync();
|
||||
histRows.Should().HaveCount(1, "[CHANGE 4] re-sign đè opinion non-empty → đúng 1 history row");
|
||||
histRows[0].EntityType.Should().Be(PurchaseEvaluationEntityType.Workflow);
|
||||
histRows[0].ContextNote.Should().Contain(round1, "history lưu snapshot ý kiến CŨ");
|
||||
|
||||
var cap1Opinion = await db.PurchaseEvaluationLevelOpinions
|
||||
.SingleAsync(o => o.PurchaseEvaluationId == pe.Id && o.ApprovalWorkflowLevelId == cap1Id);
|
||||
cap1Opinion.Comment.Should().Be(round2, "opinion overwrite bằng bản mới");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReSign_OverEmptyOpinion_LogsNoHistoryRow()
|
||||
{
|
||||
// [CHANGE 4 boundary] Opinion cũ Comment rỗng (chỉ tồn tại qua data legacy / seed trực
|
||||
// tiếp — service không bao giờ tạo rỗng vì có placeholder "(duyệt — không ý kiến)").
|
||||
// Guard `!IsNullOrWhiteSpace(existingOpinion.Comment)` → re-sign đè opinion RỖNG KHÔNG
|
||||
// ghi history row. Pin defensive-guard.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var approverA = (await fix.CreateUserAsync("appAe@s60.test", "Approver A", null, Array.Empty<string>())).Id;
|
||||
var approverB = (await fix.CreateUserAsync("appBe@s60.test", "Approver B", null, Array.Empty<string>())).Id;
|
||||
var drafterX = (await fix.CreateUserAsync("dxe@s60.test", "Drafter Ngoai", null, new[] { AppRoles.Drafter })).Id;
|
||||
|
||||
var wf = await SeedWorkflowSingleStepAsync(db, approverA, approverB); // Cấp1=A, Cấp2=B
|
||||
var cap1Id = wf.Steps.First().Levels.First(l => l.Order == 1).Id;
|
||||
var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterX, "PE-HIST-2");
|
||||
|
||||
await SubmitAsync(svc, pe, drafterX); // pointer (0,1)
|
||||
|
||||
// Seed opinion Cấp 1 với Comment RỖNG (mô phỏng data legacy).
|
||||
db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
ApprovalWorkflowLevelId = cap1Id,
|
||||
Comment = "", // rỗng — chỉ đạt được qua seed trực tiếp
|
||||
SignedAt = DateTime.UtcNow,
|
||||
SignedByUserId = approverA,
|
||||
SignedByFullName = "Approver A",
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// approverA ký Cấp 1 → đè opinion rỗng.
|
||||
await ApproveAsync(svc, pe, approverA, comment: "Ý kiến mới sau bản rỗng", roles: Array.Empty<string>());
|
||||
|
||||
(await db.PurchaseEvaluationChangelogs.CountAsync(c =>
|
||||
c.PurchaseEvaluationId == pe.Id
|
||||
&& c.EntityId == cap1Id
|
||||
&& c.Action == ChangelogAction.Update))
|
||||
.Should().Be(0, "[CHANGE 4] đè opinion rỗng → KHÔNG log history");
|
||||
|
||||
var cap1Opinion = await db.PurchaseEvaluationLevelOpinions
|
||||
.SingleAsync(o => o.PurchaseEvaluationId == pe.Id && o.ApprovalWorkflowLevelId == cap1Id);
|
||||
cap1Opinion.Comment.Should().Be("Ý kiến mới sau bản rỗng", "opinion overwrite bình thường");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user