[CLAUDE] Workflow: sua quy trinh duyet TAI CHO + co F6 AllowApproverDelete (dot 1)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m31s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m31s
Nen cho tinh nang "xoa phieu PE o man duyet" (UAT: phieu bam sai goi thau dang
an luy ke). Dot 1 lam NEN, dot 2 moi lam nut xoa.
Vi sao phai lam nen truoc: phieu dang chay pin ApprovalWorkflowId va doc workflow
SONG theo Id do. Moi POST tao workflow moi = Id MOI, nen tick co tren ban moi
KHONG BAO GIO toi duoc phieu dang treo. Phai co lenh sua-tai-cho giu nguyen Id.
- Domain: ApprovalWorkflowLevel + AllowApproverDelete (F6, per-NGUOI) + Mig
20260727033522_AddPeAllowApproverDelete (AddColumn bit default false, no new table)
- Application: UpdateAwDefinitionCommand + Validator + Handler
- 2 phep thu: (T1) khung khong doi - Order tung row bat bien
(T2) luat choi khong doi - cam CeoApprovalThreshold, AllowApproverFinalize,
Code, ApplicableType => 409 bao tao ban moi
- diff theo Level.Id (CAM delete-then-insert: no FK Restrict + mo coi LevelOpinion)
- guard 2 tang chu ky: tang 1 chu ky tren ho so CON SONG; tang 2 chu ky CON LUU
(cha xoa mem hoac opinion xoa mem) - ca hai deu CHAN, khong xoa cung
- purge chi khi ho so cha KHONG CON ROW; boc transaction (ExecuteDelete tu commit)
- KHONG tang Version, KHONG dung IsActive, KHONG doi Id
- Api: PUT /api/approval-workflows-v2/{id} + [Authorize(Policy="Workflows.Create")]
- Infra: message 403 in TEN NGUOI thay GUID tran
- FE: Designer tach "Sua tai cho" (PUT) vs "Tao phien ban moi" (POST) + o tick F6
- Tests: +21 (551 tong, 0 fail) - gom F12 chung minh phieu dang chay nhan thay doi ngay
Review: 14 FLAG, 3 HIGH da va (ExecuteDelete ngoai transaction / purge xoa cung
chu ky phieu xoa mem / endpoint khong co day FE goi toi).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@ -1,6 +1,7 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using SolutionErp.Api.Controllers;
|
||||
using SolutionErp.Domain.Identity; // MenuKeys — chốt policy name có thật (F-1b S155)
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Api;
|
||||
|
||||
@ -88,6 +89,59 @@ public class AuthorizePolicyRegressionTests
|
||||
"PATCH user-selectable chỉ admin (Mig 25 Designer pin/unpin).");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// F-1b (S155 2026-07-27) — PUT sửa quy trình TẠI CHỖ.
|
||||
//
|
||||
// 🔴 Nghiêm trọng hơn một endpoint ghi thường, vì CẢ HAI thao tác mà lệnh này
|
||||
// cho phép đều là hành vi CẤP QUYỀN:
|
||||
// (i) "thêm người vào Cấp" ⇒ tự thêm mình vào Cấp đang chờ ⇒ lọt guard
|
||||
// match-approver ⇒ DUYỆT ĐƯỢC PHIẾU NGƯỜI KHÁC;
|
||||
// (ii) "bật cờ Allow*" ⇒ gồm chính F6 AllowApproverDelete ⇒ TỰ CẤP
|
||||
// QUYỀN XOÁ phiếu đang duyệt.
|
||||
// Class-level chỉ [Authorize] trần (cố ý, gotcha #44) nên nó KHÔNG tự bảo vệ:
|
||||
// thiếu đúng 1 dòng attribute = leo thang đặc quyền cho MỌI tài khoản đăng nhập.
|
||||
// ===================================================================
|
||||
|
||||
[Fact]
|
||||
public void ApprovalWorkflowsV2Controller_Update_PUT_RequiresWorkflowsCreatePolicy()
|
||||
{
|
||||
var attr = GetActionAuthorize(typeof(ApprovalWorkflowsV2Controller), nameof(ApprovalWorkflowsV2Controller.Update));
|
||||
|
||||
attr.Should().NotBeNull(
|
||||
"PUT sửa-tại-chỗ phải có [Authorize(Policy = ...)] — class-level [Authorize] trần cho lọt mọi user đăng nhập");
|
||||
attr!.Policy.Should().Be("Workflows.Create",
|
||||
"khớp cả 3 verb ghi còn lại (POST/PATCH/DELETE) của chính controller này — sửa quy trình = quyền admin Designer.");
|
||||
}
|
||||
|
||||
// Bẫy đi kèm (spec §B3): `Program.cs` CHỈ đăng ký policy `{menu}.{action}` cho
|
||||
// menu key nằm trong `MenuKeys.All`. Gắn một policy KHÔNG được đăng ký thì
|
||||
// ASP.NET ném lúc chạy — test attribute ở trên vẫn xanh mà endpoint vẫn hỏng.
|
||||
// ⇒ chốt luôn rằng tên policy đang dùng là tên CÓ THẬT trong bộ sinh policy.
|
||||
[Fact]
|
||||
public void ApprovalWorkflowsV2Controller_WriteVerbs_PolicyName_IsActuallyRegisterable()
|
||||
{
|
||||
var writeActions = new[]
|
||||
{
|
||||
nameof(ApprovalWorkflowsV2Controller.Create),
|
||||
nameof(ApprovalWorkflowsV2Controller.Update),
|
||||
nameof(ApprovalWorkflowsV2Controller.SetUserSelectable),
|
||||
nameof(ApprovalWorkflowsV2Controller.Delete),
|
||||
};
|
||||
|
||||
foreach (var action in writeActions)
|
||||
{
|
||||
var policy = GetActionAuthorize(typeof(ApprovalWorkflowsV2Controller), action)?.Policy;
|
||||
policy.Should().NotBeNullOrEmpty($"action {action} phải có policy");
|
||||
|
||||
var parts = policy!.Split('.');
|
||||
parts.Should().HaveCount(2, $"policy {policy} phải đúng dạng {{menuKey}}.{{action}}");
|
||||
MenuKeys.All.Should().Contain(parts[0],
|
||||
$"policy \"{policy}\" của {action} trỏ menu key KHÔNG có trong MenuKeys.All ⇒ Program.cs không sinh policy đó ⇒ 500 lúc chạy (bẫy Pe_* trong spec S155 §B3)");
|
||||
MenuKeys.Actions.Should().Contain(parts[1],
|
||||
$"policy \"{policy}\" của {action} dùng action không có trong MenuKeys.Actions");
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Coverage gap #3 (MAJOR — S35/S36 backlog, closed S45 2026-06-01).
|
||||
// gotcha #44 regression cho 2 controller HRM bị MISS (chỉ ApprovalWorkflowsV2 có trước đó).
|
||||
|
||||
@ -0,0 +1,972 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Contracts; // ApprovalDecision + ChangelogAction (shared HĐ/PE)
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Office; // Proposal + ProposalLevelOpinion (F-16 blast radius)
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
using SolutionErp.Infrastructure.Tests.Services; // NoOpNotificationService
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// ============================================================================
|
||||
// S155 hạng mục F (2026-07-27) — lệnh UPDATE quy trình duyệt TẠI CHỖ.
|
||||
// Test-before-merge: đụng CẢ security (tự thêm mình vào Cấp = duyệt phiếu người
|
||||
// khác) LẪN financial (phiếu đang chạy đổi cấu hình giữa chừng) ⇒ hạng critical
|
||||
// theo docs/rules.md §7.
|
||||
//
|
||||
// Đối tượng đo: `UpdateAwDefinitionCommandHandler`
|
||||
// (src/Backend/SolutionErp.Application/ApprovalWorkflowsV2/ApprovalWorkflowV2AdminFeatures.cs:501-930)
|
||||
//
|
||||
// 🔴 MỆNH ĐỀ TRUNG TÂM (spec §F.0) mà F-12/F-15 tồn tại để chứng minh:
|
||||
// POST tạo version mới sinh **Id MỚI**, phiếu đang treo pin **Id CŨ** và đọc
|
||||
// workflow **SỐNG** (`PurchaseEvaluationWorkflowService.cs:671-675` — chỉ
|
||||
// `w.Id == awId`, không snapshot) ⇒ cấu hình tick hôm nay KHÔNG BAO GIỜ tới
|
||||
// được phiếu cần cứu. Chỉ sửa-TẠI-CHỖ (giữ nguyên Id) mới tới.
|
||||
// ⇒ mọi assert "phiếu thấy thay đổi" ở đây đều đi qua `ResolveLikeRunningPeAsync`
|
||||
// — bản sao Y HỆT đường resolve của service, không phải một query tiện tay.
|
||||
//
|
||||
// Ranh giới ĐỢT: file này KHÔNG test hành vi XOÁ phiếu khi đang duyệt (nút +
|
||||
// endpoint xoá-khi-duyệt = ĐỢT 2, chưa tồn tại trên đĩa). F-15 chỉ đo tới mức
|
||||
// "cờ F6 đọc được từ phía phiếu đang treo".
|
||||
//
|
||||
// Harness: IdentityFixture (BẮT BUỘC — `ApprovalWorkflowLevel.ApproverUserId` có
|
||||
// FK Restrict sang User, `ApprovalWorkflowConfiguration.cs:70-74`, nên approver
|
||||
// phải là user THẬT chứ không phải Guid ngẫu nhiên).
|
||||
// ============================================================================
|
||||
public class UpdateAwDefinitionTests
|
||||
{
|
||||
private sealed record Harness(
|
||||
IdentityFixture Fix,
|
||||
TestApplicationDbContext Db,
|
||||
UserManager<User> Um,
|
||||
FixedDateTime Clock);
|
||||
|
||||
private static Harness NewHarness()
|
||||
{
|
||||
var fix = new IdentityFixture();
|
||||
return new Harness(
|
||||
fix,
|
||||
fix.Services.GetRequiredService<TestApplicationDbContext>(),
|
||||
fix.Services.GetRequiredService<UserManager<User>>(),
|
||||
new FixedDateTime(new DateTime(2026, 7, 27, 3, 0, 0, DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
private static UpdateAwDefinitionCommandHandler NewHandler(Harness h, User actor)
|
||||
=> new(h.Db, new TestCurrentUser(actor.Id, actor.FullName, actor.Email), h.Clock, h.Um);
|
||||
|
||||
// stepSpecs[s] = các dòng Cấp của Bước (Order s+1). Mỗi phần tử = (LevelOrder, ApproverUserId).
|
||||
// N dòng cùng LevelOrder = 1 Cấp có N người (OR-of-N) — đúng hình dạng prod.
|
||||
private static async Task<ApprovalWorkflow> SeedWorkflowAsync(
|
||||
TestApplicationDbContext db,
|
||||
ApprovalWorkflowApplicableType type,
|
||||
(int LevelOrder, Guid User)[][] stepSpecs,
|
||||
decimal? ceoThreshold = null,
|
||||
string code = "QT-UPD-V2")
|
||||
{
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Code = code,
|
||||
Version = 1,
|
||||
ApplicableType = type,
|
||||
Name = "QT test sửa tại chỗ",
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
CeoApprovalThreshold = ceoThreshold,
|
||||
ActivatedAt = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
for (int s = 0; s < stepSpecs.Length; s++)
|
||||
{
|
||||
var step = new ApprovalWorkflowStep
|
||||
{
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
Order = s + 1,
|
||||
Name = $"Bước {s + 1}",
|
||||
};
|
||||
foreach (var (levelOrder, user) in stepSpecs[s])
|
||||
{
|
||||
step.Levels.Add(new ApprovalWorkflowLevel
|
||||
{
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = levelOrder,
|
||||
Name = $"Cấp {levelOrder}",
|
||||
ApproverUserId = user,
|
||||
});
|
||||
}
|
||||
wf.Steps.Add(step);
|
||||
}
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return wf;
|
||||
}
|
||||
|
||||
private static async Task<PurchaseEvaluation> SeedPeAsync(
|
||||
TestApplicationDbContext db,
|
||||
Guid workflowId,
|
||||
Guid drafterId,
|
||||
string code,
|
||||
int stepIdx = 0,
|
||||
int levelOrder = 1,
|
||||
PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
bool isDeleted = false)
|
||||
{
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = phase,
|
||||
MaPhieu = code,
|
||||
TenGoiThau = "Gói thầu test sửa quy trình tại chỗ",
|
||||
ProjectId = Guid.NewGuid(),
|
||||
DrafterUserId = drafterId,
|
||||
ApprovalWorkflowId = workflowId,
|
||||
CurrentWorkflowStepIndex = phase == PurchaseEvaluationPhase.ChoDuyet ? stepIdx : null,
|
||||
CurrentApprovalLevelOrder = phase == PurchaseEvaluationPhase.ChoDuyet ? levelOrder : null,
|
||||
IsDeleted = isDeleted,
|
||||
DeletedAt = isDeleted ? new DateTime(2026, 7, 26, 0, 0, 0, DateTimeKind.Utc) : null,
|
||||
};
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return pe;
|
||||
}
|
||||
|
||||
// 🔴 BẢN SAO Y HỆT đường resolve của phiếu đang chạy
|
||||
// (`PurchaseEvaluationWorkflowService.cs:671-675`): chỉ lọc theo Id đã pin,
|
||||
// Include Steps/Levels đã sắp xếp, AsNoTracking. Dùng nó (chứ không phải một
|
||||
// query tiện tay) để assert "phiếu THẤY thay đổi" mới là bằng chứng thật.
|
||||
private static Task<ApprovalWorkflow> ResolveLikeRunningPeAsync(TestApplicationDbContext db, Guid pinnedId)
|
||||
=> db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps.OrderBy(s => s.Order))
|
||||
.ThenInclude(s => s.Levels.OrderBy(l => l.Order))
|
||||
.FirstAsync(w => w.Id == pinnedId, CancellationToken.None);
|
||||
|
||||
// Lệnh Update "trung thực": echo NGUYÊN TRẠNG mọi thứ đang lưu. Mọi test chỉ
|
||||
// đổi ĐÚNG MỘT biến trên nền này (kỹ thuật cặp cùng-ruột-khác-1-biến) — nếu
|
||||
// test đỏ thì biết chắc do biến đó chứ không do payload lệch chỗ khác.
|
||||
private static UpdateAwDefinitionCommand BuildEchoCommand(ApprovalWorkflow wf, string? reason = null)
|
||||
=> new(
|
||||
Id: wf.Id,
|
||||
Name: wf.Name,
|
||||
Description: wf.Description,
|
||||
Steps: wf.Steps.OrderBy(s => s.Order).Select(s => new UpdateAwStepInput(
|
||||
Id: s.Id,
|
||||
Order: s.Order,
|
||||
Name: s.Name,
|
||||
DepartmentId: s.DepartmentId,
|
||||
Levels: s.Levels.OrderBy(l => l.Order).Select(EchoLevel).ToList())).ToList(),
|
||||
Reason: reason,
|
||||
Code: null,
|
||||
ApplicableType: null,
|
||||
CeoApprovalThreshold: wf.CeoApprovalThreshold);
|
||||
|
||||
private static UpdateAwLevelInput EchoLevel(ApprovalWorkflowLevel l) => new(
|
||||
Id: l.Id,
|
||||
Order: l.Order,
|
||||
Name: l.Name,
|
||||
ApproverUserId: l.ApproverUserId,
|
||||
AllowReturnOneLevel: l.AllowReturnOneLevel,
|
||||
AllowReturnOneStep: l.AllowReturnOneStep,
|
||||
AllowReturnToAssignee: l.AllowReturnToAssignee,
|
||||
AllowReturnToDrafter: l.AllowReturnToDrafter,
|
||||
AllowApproverEditDetails: l.AllowApproverEditDetails,
|
||||
AllowApproverEditBudget: l.AllowApproverEditBudget,
|
||||
AllowApproverDelete: l.AllowApproverDelete,
|
||||
AllowApproverSkipToFinal: null, // ECHO-only, null = không khai báo
|
||||
AllowApproverFinalize: null);
|
||||
|
||||
private static UpdateAwLevelInput NewLevel(int order, Guid approverUserId, bool allowDelete = false)
|
||||
=> new(Id: null, Order: order, Name: $"Cấp {order}", ApproverUserId: approverUserId,
|
||||
AllowApproverDelete: allowDelete);
|
||||
|
||||
// Thay danh sách Cấp của Bước thứ `stepIdx` trong lệnh (giữ nguyên phần còn lại).
|
||||
private static UpdateAwDefinitionCommand WithLevels(
|
||||
UpdateAwDefinitionCommand cmd, int stepIdx, List<UpdateAwLevelInput> levels)
|
||||
{
|
||||
var steps = cmd.Steps.ToList();
|
||||
steps[stepIdx] = steps[stepIdx] with { Levels = levels };
|
||||
return cmd with { Steps = steps };
|
||||
}
|
||||
|
||||
private static PurchaseEvaluationWorkflowService NewPeService(Harness h)
|
||||
=> new(h.Db, h.Clock, new NoOpNotificationService(), h.Um);
|
||||
|
||||
private static Task ApproveAsync(PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorId)
|
||||
=> svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.ChoDuyet, // approve-in-place
|
||||
actorUserId: actorId,
|
||||
actorRoles: Array.Empty<string>(), // KHÔNG Admin — phải qua guard thật
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: null,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
// =====================================================================
|
||||
// F-12 ⭐ BÀI KIỂM CHỨNG TRUNG TÂM (spec §F.0) — sửa hạng AN TOÀN trên
|
||||
// workflow ĐANG CÓ PHIẾU CHẠY ⇒ phiếu nhận thay đổi NGAY.
|
||||
//
|
||||
// Đo bằng HÀNH VI THẬT chứ không bằng một cột trong bảng: cùng một phiếu,
|
||||
// cùng một lời gọi duyệt của cùng một người — chỉ khác nhau ở chỗ lệnh Update
|
||||
// đã chạy hay chưa. Trước: Forbidden. Sau: duyệt được + con trỏ tiến.
|
||||
// (Nếu chỉ assert "DB có row mới" thì test vẫn xanh kể cả khi phiếu đọc
|
||||
// snapshot cũ — tức mù đúng chỗ spec sợ nhất.)
|
||||
// Kèm luôn F-4 (Id/Version/IsActive bất biến) và F-6 (403 in TÊN, không GUID).
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task F12_SafeUpdate_AddsPersonToExistingLevel_RunningPeAcceptsNewApproverImmediately()
|
||||
{
|
||||
var h = NewHarness();
|
||||
using (h.Fix)
|
||||
{
|
||||
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "Trần Thị An", null, []);
|
||||
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Lê Văn Bình", null, []);
|
||||
var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Phạm Thị Cúc", null, []);
|
||||
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
|
||||
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
|
||||
|
||||
// Bước 1: Cấp 1 = alice · Cấp 2 = carol. Phiếu treo tại (Bước 1, Cấp 1).
|
||||
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
[[(1, alice.Id), (2, carol.Id)]]);
|
||||
var pe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F12-001");
|
||||
var svc = NewPeService(h);
|
||||
|
||||
// --- TRƯỚC: bob KHÔNG duyệt được (nửa contrast — chống vacuous pass) ---
|
||||
var beforeAct = async () => await ApproveAsync(svc, pe, bob.Id);
|
||||
var forbidden = (await beforeAct.Should().ThrowAsync<ForbiddenException>(
|
||||
"bob chưa nằm trong Cấp 1 nên phải bị chặn TRƯỚC khi sửa quy trình")).Which;
|
||||
// F-6 (lead tự vá, chưa ai soi): message phải in TÊN người duyệt, không phải GUID trần.
|
||||
forbidden.Message.Should().Contain("Trần Thị An",
|
||||
"F-6: thông điệp 403 phải resolve GUID sang tên thật để người dùng biết nhờ ai duyệt");
|
||||
forbidden.Message.Should().NotContain(alice.Id.ToString(),
|
||||
"F-6: không được rơi lại về in GUID trần khi tra được tên");
|
||||
|
||||
// --- ACT: thêm bob vào CẤP CÓ SẴN (row trùng Order 1) ---
|
||||
var step = wf.Steps.Single();
|
||||
var lvlAlice = step.Levels.Single(l => l.ApproverUserId == alice.Id);
|
||||
var lvlCarol = step.Levels.Single(l => l.ApproverUserId == carol.Id);
|
||||
var cmd = WithLevels(BuildEchoCommand(wf), 0,
|
||||
[EchoLevel(lvlAlice), NewLevel(1, bob.Id), EchoLevel(lvlCarol)]);
|
||||
|
||||
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
|
||||
|
||||
// --- F-4: giữ Id / Version / IsActive — giữ Id là TOÀN BỘ mục đích ---
|
||||
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
|
||||
live.Id.Should().Be(wf.Id);
|
||||
live.Version.Should().Be(1, "sửa tại chỗ KHÔNG được tăng Version");
|
||||
live.IsActive.Should().BeTrue("sửa tại chỗ KHÔNG được đụng IsActive");
|
||||
live.Steps.Should().HaveCount(1);
|
||||
live.Steps[0].Levels.Where(l => l.Order == 1).Select(l => l.ApproverUserId)
|
||||
.Should().BeEquivalentTo(new[] { alice.Id, bob.Id });
|
||||
|
||||
var peRow = await h.Db.PurchaseEvaluations.AsNoTracking().FirstAsync(p => p.Id == pe.Id);
|
||||
peRow.ApprovalWorkflowId.Should().Be(wf.Id, "phiếu vẫn pin ĐÚNG Id cũ — không có đường re-pin");
|
||||
|
||||
// --- SAU: cùng phiếu, cùng người, cùng lời gọi ⇒ nay duyệt ĐƯỢC ---
|
||||
await ApproveAsync(svc, pe, bob.Id);
|
||||
|
||||
var peAfter = await h.Db.PurchaseEvaluations.AsNoTracking().FirstAsync(p => p.Id == pe.Id);
|
||||
peAfter.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
|
||||
peAfter.CurrentApprovalLevelOrder.Should().Be(2,
|
||||
"bob duyệt xong Cấp 1 ⇒ con trỏ tiến sang Cấp 2 — phiếu chạy trên cấu hình MỚI");
|
||||
|
||||
// Chữ ký gắn vào ĐÚNG dòng Cấp mới của bob (không dạt sang row alice).
|
||||
var newLevelId = live.Steps[0].Levels.Single(l => l.ApproverUserId == bob.Id).Id;
|
||||
var opinion = await h.Db.PurchaseEvaluationLevelOpinions.AsNoTracking()
|
||||
.SingleAsync(o => o.PurchaseEvaluationId == pe.Id);
|
||||
opinion.ApprovalWorkflowLevelId.Should().Be(newLevelId);
|
||||
opinion.SignedByUserId.Should().Be(bob.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// F-13 — đụng tập Order ⇒ 409 VÀ workflow trong DB KHÔNG đổi.
|
||||
// Cả 2 vế: chỉ assert "có ném" là mù trước ca handler ném SAU khi đã ghi.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task F13_AddNewLevelOrder_Rejected409_AndWorkflowUnchanged()
|
||||
{
|
||||
var h = NewHarness();
|
||||
using (h.Fix)
|
||||
{
|
||||
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
|
||||
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Bình", null, []);
|
||||
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
|
||||
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
[[(1, alice.Id)]]);
|
||||
var lvlAlice = wf.Steps.Single().Levels.Single();
|
||||
|
||||
// Thêm Cấp 2 (Order chưa từng có) = chèn chặng cho phiếu đang chạy.
|
||||
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice), NewLevel(2, bob.Id)]);
|
||||
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>().WithMessage("*không được thêm Cấp mới*");
|
||||
|
||||
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
|
||||
live.Steps.Single().Levels.Should().HaveCount(1, "409 rồi thì DB phải y nguyên");
|
||||
live.Steps.Single().Levels.Single().ApproverUserId.Should().Be(alice.Id);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task F13_RemoveWholeLevel_Rejected409_AndWorkflowUnchanged()
|
||||
{
|
||||
var h = NewHarness();
|
||||
using (h.Fix)
|
||||
{
|
||||
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
|
||||
var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
|
||||
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
|
||||
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
[[(1, alice.Id), (2, carol.Id)]]);
|
||||
var lvlAlice = wf.Steps.Single().Levels.Single(l => l.Order == 1);
|
||||
|
||||
// Bỏ TOÀN BỘ Cấp 2 ⇒ phiếu đang chờ ở Cấp 2 sẽ KẸT.
|
||||
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
|
||||
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>().WithMessage("*KHÔNG CÒN người duyệt*");
|
||||
|
||||
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
|
||||
live.Steps.Single().Levels.Select(l => l.Order).Should().BeEquivalentTo(new[] { 1, 2 });
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task F13_MovePersonBetweenLevelOrders_Rejected409_AndWorkflowUnchanged()
|
||||
{
|
||||
var h = NewHarness();
|
||||
using (h.Fix)
|
||||
{
|
||||
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
|
||||
var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
|
||||
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
|
||||
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
[[(1, alice.Id), (2, carol.Id)]]);
|
||||
var step = wf.Steps.Single();
|
||||
var lvlAlice = step.Levels.Single(l => l.Order == 1);
|
||||
var lvlCarol = step.Levels.Single(l => l.Order == 2);
|
||||
|
||||
// Hoán vị Cấp giữa 2 dòng: TẬP {1,2} GIỮ NGUYÊN nên set-equality một
|
||||
// mình sẽ cho qua — chốt chặn thật là "Order của TỪNG dòng bất biến".
|
||||
var cmd = WithLevels(BuildEchoCommand(wf), 0,
|
||||
[EchoLevel(lvlAlice) with { Order = 2 }, EchoLevel(lvlCarol) with { Order = 1 }]);
|
||||
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>().WithMessage("*không được chuyển người duyệt từ Cấp*");
|
||||
|
||||
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
|
||||
live.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id).Order.Should().Be(1);
|
||||
live.Steps.Single().Levels.Single(l => l.ApproverUserId == carol.Id).Order.Should().Be(2);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task F13_SwapStepOrders_KeepsOrderSet_ButStillRejected409()
|
||||
{
|
||||
var h = NewHarness();
|
||||
using (h.Fix)
|
||||
{
|
||||
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
|
||||
var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
|
||||
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
|
||||
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
[[(1, alice.Id)], [(1, carol.Id)]]);
|
||||
|
||||
// Đảo Order 2 Bước: tập {Step.Order} = {1,2} KHÔNG đổi ⇒ phép thử
|
||||
// set-equality "khung không đổi" theo nghĩa yếu sẽ cho lọt, nhưng con
|
||||
// trỏ phiếu là INDEX vào list đã sort nên nó trỏ sang Bước KHÁC, ÂM THẦM.
|
||||
var echo = BuildEchoCommand(wf);
|
||||
var swapped = new List<UpdateAwStepInput>
|
||||
{
|
||||
echo.Steps[0] with { Order = 2 },
|
||||
echo.Steps[1] with { Order = 1 },
|
||||
};
|
||||
var act = async () => await NewHandler(h, admin).Handle(echo with { Steps = swapped }, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>().WithMessage("*không được đổi thứ tự Bước*");
|
||||
|
||||
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
|
||||
live.Steps.Single(s => s.Levels.Any(l => l.ApproverUserId == alice.Id)).Order.Should().Be(1);
|
||||
live.Steps.Single(s => s.Levels.Any(l => l.ApproverUserId == carol.Id)).Order.Should().Be(2);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// F-13b (T2) — 2 trường owner xếp PHÁ VỠ: CeoApprovalThreshold + F5.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task F13b_ChangeCeoApprovalThreshold_Rejected409_AndValueUnchanged()
|
||||
{
|
||||
var h = NewHarness();
|
||||
using (h.Fix)
|
||||
{
|
||||
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
|
||||
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
|
||||
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
[[(1, alice.Id)]], ceoThreshold: 500_000_000m);
|
||||
|
||||
var cmd = BuildEchoCommand(wf) with { CeoApprovalThreshold = 900_000_000m };
|
||||
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>().WithMessage("*Ngưỡng giá trị gói CEO*");
|
||||
|
||||
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
|
||||
live.CeoApprovalThreshold.Should().Be(500_000_000m);
|
||||
}
|
||||
}
|
||||
|
||||
// Vế IM LẶNG của cùng luật: client KHÔNG khai báo ngưỡng (null) trong khi DB
|
||||
// đang có ngưỡng. Nếu handler đọc null là "bỏ qua" thì admin xoá trắng ô ngưỡng
|
||||
// sẽ nhận 204 mà số không đổi — sai im lặng, đúng lớp lỗi spec đang đi chữa.
|
||||
[Fact]
|
||||
public async Task F13b_OmitCeoApprovalThreshold_WhenStored_Rejected409_NoSilentNoop()
|
||||
{
|
||||
var h = NewHarness();
|
||||
using (h.Fix)
|
||||
{
|
||||
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
|
||||
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
|
||||
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
[[(1, alice.Id)]], ceoThreshold: 500_000_000m);
|
||||
|
||||
var cmd = BuildEchoCommand(wf) with { CeoApprovalThreshold = null, Name = "Tên mới" };
|
||||
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>().WithMessage("*Ngưỡng giá trị gói CEO*");
|
||||
|
||||
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
|
||||
live.CeoApprovalThreshold.Should().Be(500_000_000m);
|
||||
live.Name.Should().Be("QT test sửa tại chỗ", "409 thì KHÔNG phần nào của lệnh được ghi");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task F13b_ToggleAllowApproverFinalize_Rejected409_AndFlagUnchanged()
|
||||
{
|
||||
var h = NewHarness();
|
||||
using (h.Fix)
|
||||
{
|
||||
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
|
||||
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
|
||||
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
[[(1, alice.Id)]]);
|
||||
var lvl = wf.Steps.Single().Levels.Single();
|
||||
|
||||
// F5 = ĐỔI ĐIỂM KẾT THÚC của phiếu (Phase=DaDuyet + đóng băng số tiền
|
||||
// ngay lúc duyệt) ⇒ không phải "quyền tại chỗ".
|
||||
var cmd = WithLevels(BuildEchoCommand(wf), 0,
|
||||
[EchoLevel(lvl) with { AllowApproverFinalize = true }]);
|
||||
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>().WithMessage("*Duyệt là kết thúc*");
|
||||
|
||||
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
|
||||
live.Steps.Single().Levels.Single().AllowApproverFinalize.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
// Nửa còn lại của "ECHO-only, KHÔNG BAO GIỜ ghi": khi client bỏ trống 2 cờ
|
||||
// đổi-đường-đi thì giá trị đang lưu phải GIỮ NGUYÊN. Đây mới là chốt chặn hồi
|
||||
// quy thật — nếu ai đó sau này viết `db.X = req.X ?? false` thì F5 của người
|
||||
// đang giữ quyền kết-thúc bị TẮT âm thầm qua một lần đổi tên vô hại.
|
||||
[Fact]
|
||||
public async Task F13b_OmittedFinalizeEcho_PreservesStoredFlags_NotSilentlyCleared()
|
||||
{
|
||||
var h = NewHarness();
|
||||
using (h.Fix)
|
||||
{
|
||||
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
|
||||
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
|
||||
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
[[(1, alice.Id)]]);
|
||||
var lvl = wf.Steps.Single().Levels.Single();
|
||||
lvl.AllowApproverFinalize = true;
|
||||
lvl.AllowApproverSkipToFinal = true;
|
||||
await h.Db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// Lệnh hợp lệ, chỉ đổi tên — 2 cờ để null (không khai báo).
|
||||
var cmd = BuildEchoCommand(wf) with { Name = "Tên quy trình đã đổi" };
|
||||
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
|
||||
|
||||
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
|
||||
live.Name.Should().Be("Tên quy trình đã đổi");
|
||||
var liveLevel = live.Steps.Single().Levels.Single();
|
||||
liveLevel.AllowApproverFinalize.Should().BeTrue("cờ đổi-đường-đi là echo-only, không được ghi đè bằng null");
|
||||
liveLevel.AllowApproverSkipToFinal.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// F-14 — bớt người khỏi Cấp mà dòng đó ĐÃ CÓ chữ ký ⇒ chặn LỊCH SỰ (409),
|
||||
// KHÔNG để nổ FK 547 → 500.
|
||||
// Kèm CHỨNG-NHÂN: cuối test tự tay xoá dòng đó ⇒ DB nổ thật. Không có đoạn
|
||||
// này thì assert "409" chỉ chứng minh handler biết ném, chứ không chứng minh
|
||||
// nó đang đỡ một quả nổ có thật.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task F14_RemoveApproverWithLiveOpinion_BlockedPolitely_NotFkViolation()
|
||||
{
|
||||
var h = NewHarness();
|
||||
using (h.Fix)
|
||||
{
|
||||
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
|
||||
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Lê Văn Bình", null, []);
|
||||
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
|
||||
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
|
||||
|
||||
// Cấp 1 có 2 người ⇒ bỏ bob KHÔNG làm Cấp rỗng (tách bạch với F-13).
|
||||
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
[[(1, alice.Id), (1, bob.Id)]]);
|
||||
var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
|
||||
var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);
|
||||
|
||||
var pe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F14-001");
|
||||
h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
ApprovalWorkflowLevelId = lvlBob.Id,
|
||||
Comment = "Đồng ý",
|
||||
SignedAt = h.Clock.UtcNow,
|
||||
SignedByUserId = bob.Id,
|
||||
SignedByFullName = "Lê Văn Bình",
|
||||
});
|
||||
await h.Db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
|
||||
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
|
||||
|
||||
var ex = (await act.Should().ThrowAsync<ConflictException>(
|
||||
"chữ ký trên phiếu CÒN SỐNG phải chặn việc bỏ người, và chặn LỊCH SỰ")).Which;
|
||||
ex.Message.Should().Contain("chữ ký");
|
||||
|
||||
// DB nguyên vẹn: cả dòng Cấp lẫn chữ ký đều còn.
|
||||
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
|
||||
live.Steps.Single().Levels.Should().HaveCount(2);
|
||||
(await h.Db.PurchaseEvaluationLevelOpinions.AsNoTracking()
|
||||
.CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id)).Should().Be(1);
|
||||
|
||||
// CHỨNG-NHÂN: nếu không có guard thì đây là thứ người dùng nhận (FK Restrict).
|
||||
// 🔴 Phải dọn change-tracker TRƯỚC: chữ ký ở trên do CHÍNH test `Add` vào cùng
|
||||
// context nên nó vẫn đang được theo dõi. Khi dependent còn tracked, `Remove(level)`
|
||||
// kích cascade-check CLIENT-SIDE của EF (CascadeDeleteTiming.Immediate = mặc định)
|
||||
// và ném InvalidOperationException("...has been severed...") NGAY TẠI `Remove` —
|
||||
// chưa từng chạm DB ⇒ dòng assert bên dưới KHÔNG BAO GIỜ được chấm. Dọn tracker
|
||||
// để chứng-nhân đo ĐÚNG cái tầng nó khai: ràng buộc FK ở DB.
|
||||
// (Prod không dính: context mỗi request không nạp opinion nào, guard đọc chữ ký
|
||||
// bằng AsNoTracking ⇒ tới `Remove` là tracker vốn đã sạch.)
|
||||
h.Db.ChangeTracker.Clear();
|
||||
h.Db.ApprovalWorkflowLevels.Remove(lvlBob);
|
||||
var raw = async () => await h.Db.SaveChangesAsync(CancellationToken.None);
|
||||
await raw.Should().ThrowAsync<DbUpdateException>(
|
||||
"FK Restrict LevelOpinion→Level là thật — guard 409 đang đỡ đúng quả nổ này");
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 F-2 (reviewer đợt 1) — TIÊU CHÍ NGHIỆM THU #1. Cùng RUỘT với test trên,
|
||||
// khác ĐÚNG MỘT BIT: phiếu mang chữ ký đó đã bị xoá MỀM.
|
||||
//
|
||||
// ĐẢO SO VỚI BẢN TRƯỚC (bản trước tên `..._RemovalAllowed_AndOrphanPurged`, khẳng
|
||||
// định "cho gỡ + xoá cứng chữ ký"). Vì sao đảo — không phải nới/siết cho hợp code:
|
||||
// xoá mềm là KHÔI PHỤC ĐƯỢC và §A spec S155 có màn "Đã xoá" phải đọc được lịch
|
||||
// sử ký. Xoá cứng chữ ký của phiếu xoá mềm = phiếu khôi phục lên mất trắng lịch
|
||||
// sử duyệt, IM LẶNG, không dấu vết (changelog cũ chỉ ghi một con SỐ ĐẾM, không
|
||||
// ghi phiếu nào). Đó là đúng class lỗi mà cả spec S155 đang đi chữa.
|
||||
// "Bị chặn OAN" (lý lẽ cũ) không phải là mất mát: admin còn 2 đường thoát rõ ràng
|
||||
// (khôi phục phiếu rồi xử lý, hoặc xoá HẲN phiếu) — cả hai đều hoàn tác được.
|
||||
[Fact]
|
||||
public async Task F2_OpinionBelongsToSoftDeletedPe_BlockedPolitely_AndSignatureKept()
|
||||
{
|
||||
var h = NewHarness();
|
||||
using (h.Fix)
|
||||
{
|
||||
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
|
||||
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Bình", null, []);
|
||||
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
|
||||
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
|
||||
|
||||
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
[[(1, alice.Id), (1, bob.Id)]]);
|
||||
var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
|
||||
var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);
|
||||
|
||||
var deletedPe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F14b-001", isDeleted: true);
|
||||
h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
|
||||
{
|
||||
PurchaseEvaluationId = deletedPe.Id,
|
||||
ApprovalWorkflowLevelId = lvlBob.Id,
|
||||
Comment = "Đồng ý",
|
||||
SignedAt = h.Clock.UtcNow,
|
||||
SignedByUserId = bob.Id,
|
||||
SignedByFullName = "Bình",
|
||||
});
|
||||
await h.Db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
|
||||
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
|
||||
|
||||
var ex = (await act.Should().ThrowAsync<ConflictException>(
|
||||
"chữ ký của phiếu XOÁ MỀM vẫn là lịch sử duyệt khôi phục được")).Which;
|
||||
// Thông điệp phải NÊU TÊN phiếu đang giữ chữ ký — không thì admin không biết gỡ gì.
|
||||
ex.Message.Should().Contain("PE-F14b-001").And.Contain("đã xoá");
|
||||
|
||||
// 🔴 Điều F-2 đòi: chữ ký VẪN CÒN. Đọc bằng IgnoreQueryFilters để không bị
|
||||
// chính global filter che mất row (đọc thiếu sẽ trông y hệt "đã bị xoá").
|
||||
(await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
|
||||
.CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id)).Should().Be(1);
|
||||
|
||||
// Và quy trình cũng không bị sứt: cả 2 dòng Cấp còn nguyên.
|
||||
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
|
||||
live.Steps.Single().Levels.Should().HaveCount(2);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 F-2 — TIÊU CHÍ NGHIỆM THU #2, vế thứ HAI của điều kiện purge cũ (`o.IsDeleted`):
|
||||
// ý kiến TỰ NÓ xoá mềm, trên phiếu CÒN SỐNG. Đó là bản ghi KIỂM TOÁN của tài liệu
|
||||
// đang hiệu lực — bản trước đem xoá CỨNG luôn. Cặp cùng-ruột-khác-1-bit với test
|
||||
// trên: ở đó cha xoá mềm / ý kiến sống, ở đây cha sống / ý kiến xoá mềm.
|
||||
[Fact]
|
||||
public async Task F2_SoftDeletedOpinionOnLivePe_NotHardPurged_BlockedAndKept()
|
||||
{
|
||||
var h = NewHarness();
|
||||
using (h.Fix)
|
||||
{
|
||||
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
|
||||
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Bình", null, []);
|
||||
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
|
||||
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
|
||||
|
||||
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
[[(1, alice.Id), (1, bob.Id)]]);
|
||||
var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
|
||||
var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);
|
||||
|
||||
// Phiếu CÒN SỐNG (không isDeleted) — chỉ ý kiến bị thu hồi (xoá mềm).
|
||||
var livePe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F2b-001");
|
||||
h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
|
||||
{
|
||||
PurchaseEvaluationId = livePe.Id,
|
||||
ApprovalWorkflowLevelId = lvlBob.Id,
|
||||
Comment = "Đồng ý (sau đó thu hồi)",
|
||||
SignedAt = h.Clock.UtcNow,
|
||||
SignedByUserId = bob.Id,
|
||||
SignedByFullName = "Bình",
|
||||
IsDeleted = true,
|
||||
DeletedAt = new DateTime(2026, 7, 26, 0, 0, 0, DateTimeKind.Utc),
|
||||
});
|
||||
await h.Db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// Tầng 1 (`CountLiveOpinionsAsync`) CỐ TÌNH bỏ qua row này (`!o.IsDeleted`)
|
||||
// ⇒ nếu tầng 2 không tồn tại thì đây rơi thẳng vào purge và mất vĩnh viễn.
|
||||
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
|
||||
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
|
||||
|
||||
var ex = (await act.Should().ThrowAsync<ConflictException>(
|
||||
"ý kiến đã thu hồi trên phiếu đang hiệu lực là bản ghi kiểm toán")).Which;
|
||||
ex.Message.Should().Contain("PE-F2b-001").And.Contain("thu hồi");
|
||||
|
||||
(await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
|
||||
.CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id)).Should().Be(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 F-1 (reviewer đợt 1) — TIÊU CHÍ NGHIỆM THU: purge (`ExecuteDelete`) auto-commit
|
||||
// NGAY khi không có transaction ambient, mà `SaveChangesAsync` thì mãi cuối method.
|
||||
// Ném ở khoảng GIỮA ⇒ mọi thứ sau rollback, chữ ký đã xoá cứng thì KHÔNG quay lại.
|
||||
//
|
||||
// Dựng lại đúng hình đó, KHÔNG mock:
|
||||
// (1) chữ ký MỒ CÔI THẬT — phiếu cha đã không còn row. Không dựng được bằng đường
|
||||
// ứng dụng (FK cha→opinion là Cascade: xoá cứng cha thì chữ ký đi theo), nên
|
||||
// tắt `PRAGMA foreign_keys` đúng một câu DELETE để mô phỏng dữ liệu lệch sinh
|
||||
// ngoài luồng — chính thứ mà purge tồn tại để dọn.
|
||||
// (2) BẪY ném SAU purge: một changelog trỏ vào phiếu không tồn tại, gài sẵn trong
|
||||
// change-tracker ⇒ `SaveChangesAsync` cuối method nổ FK. Hiện thực đúng luồng
|
||||
// thật: context là scoped-theo-request, handler khác trong cùng request có thể
|
||||
// để lại thay đổi đang chờ.
|
||||
// Nếu rào giao dịch KHÔNG có: purge đã commit ⇒ assert dưới thấy 0 ⇒ đỏ.
|
||||
[Fact]
|
||||
public async Task F1_SaveChangesThrowsAfterPurge_RollsBackWholeCommand_OrphanSignaturesKept()
|
||||
{
|
||||
var h = NewHarness();
|
||||
using (h.Fix)
|
||||
{
|
||||
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
|
||||
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Bình", null, []);
|
||||
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
|
||||
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
|
||||
|
||||
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
[[(1, alice.Id), (1, bob.Id)]]);
|
||||
var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
|
||||
var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);
|
||||
|
||||
// Phiếu SỐNG pin quy trình này ⇒ nhánh ghi changelog (F-1c) có việc để làm,
|
||||
// nên `SaveChanges` cuối method thật sự được gọi.
|
||||
await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1-LIVE");
|
||||
|
||||
var ghostPe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1-GHOST");
|
||||
h.Db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
|
||||
{
|
||||
PurchaseEvaluationId = ghostPe.Id,
|
||||
ApprovalWorkflowLevelId = lvlBob.Id,
|
||||
Comment = "Đồng ý",
|
||||
SignedAt = h.Clock.UtcNow,
|
||||
SignedByUserId = bob.Id,
|
||||
SignedByFullName = "Bình",
|
||||
});
|
||||
await h.Db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// (1) Xoá HẲN phiếu cha nhưng chặn cascade ⇒ còn lại chữ ký MỒ CÔI THẬT.
|
||||
await h.Db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF;");
|
||||
await h.Db.Database.ExecuteSqlRawAsync(
|
||||
"DELETE FROM PurchaseEvaluations WHERE Id = {0}", ghostPe.Id);
|
||||
await h.Db.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON;");
|
||||
h.Db.ChangeTracker.Clear(); // tracker vẫn giữ bản sao — dọn cho khớp DB
|
||||
|
||||
(await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
|
||||
.CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id))
|
||||
.Should().Be(1, "tiền đề: phải có đúng 1 chữ ký mồ côi cho purge nhắm tới");
|
||||
|
||||
// (2) BẪY — sẽ nổ FK tại `SaveChangesAsync`, tức SAU khi purge đã chạy.
|
||||
h.Db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||
{
|
||||
PurchaseEvaluationId = Guid.NewGuid(), // phiếu KHÔNG tồn tại
|
||||
EntityType = PurchaseEvaluationEntityType.Workflow,
|
||||
Action = ChangelogAction.Update,
|
||||
UserId = admin.Id,
|
||||
UserName = "Quản trị",
|
||||
Summary = "Bẫy: ép SaveChanges ném SAU khi purge đã chạy",
|
||||
});
|
||||
|
||||
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
|
||||
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
|
||||
await act.Should().ThrowAsync<DbUpdateException>(
|
||||
"bẫy phải nổ ở SaveChanges — nếu không thì test này không đo được cái nó khai");
|
||||
|
||||
// 🔴 Điều F-1 đòi: số row chữ ký KHÔNG ĐỔI sau lệnh thất bại.
|
||||
h.Db.ChangeTracker.Clear();
|
||||
(await h.Db.PurchaseEvaluationLevelOpinions.IgnoreQueryFilters()
|
||||
.CountAsync(o => o.ApprovalWorkflowLevelId == lvlBob.Id))
|
||||
.Should().Be(1, "purge phải cùng rollback với SaveChanges, không auto-commit riêng");
|
||||
|
||||
// Và phần còn lại của lệnh cũng rollback trọn: dòng Cấp chưa bị gỡ.
|
||||
(await h.Db.ApprovalWorkflowLevels.AsNoTracking()
|
||||
.CountAsync(l => l.Id == lvlBob.Id)).Should().Be(1);
|
||||
(await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().CountAsync()).Should().Be(0);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// F-15 (NỬA TRƯỚC) — bật cờ F6 `AllowApproverDelete` bằng Update trên
|
||||
// workflow đang có phiếu ChoDuyet treo ⇒ cờ ĐỌC ĐƯỢC từ phía phiếu đang chạy.
|
||||
//
|
||||
// 🔴 GIỚI HẠN CỐ Ý: KHÔNG test hành vi XOÁ phiếu. Nút xoá ở màn duyệt +
|
||||
// endpoint xoá-khi-đang-duyệt thuộc ĐỢT 2 và CHƯA TỒN TẠI trên đĩa lúc viết
|
||||
// test này. Ở đây chỉ chứng minh mắt xích §F.0: cờ tick hôm nay TỚI ĐƯỢC
|
||||
// phiếu treo từ hôm qua (điều mà POST-tạo-version-mới không làm được).
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task F15_EnableAllowApproverDeleteViaUpdate_FlagReachesAlreadyPendingPe_DeleteBehaviorIsWave2()
|
||||
{
|
||||
var h = NewHarness();
|
||||
using (h.Fix)
|
||||
{
|
||||
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
|
||||
var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
|
||||
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
|
||||
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
|
||||
|
||||
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
[[(1, alice.Id), (2, carol.Id)]]);
|
||||
// Phiếu treo TỪ TRƯỚC khi admin tick cờ.
|
||||
var pe = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F15-001");
|
||||
|
||||
var before = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
|
||||
before.Steps[0].Levels.Single(l => l.ApproverUserId == alice.Id)
|
||||
.AllowApproverDelete.Should().BeFalse("nền: cờ F6 mặc định tắt");
|
||||
|
||||
var step = wf.Steps.Single();
|
||||
var lvlAlice = step.Levels.Single(l => l.ApproverUserId == alice.Id);
|
||||
var lvlCarol = step.Levels.Single(l => l.ApproverUserId == carol.Id);
|
||||
var cmd = WithLevels(BuildEchoCommand(wf, reason: "Bấm sai gói thầu — cần cho phép xoá"), 0,
|
||||
[EchoLevel(lvlAlice) with { AllowApproverDelete = true }, EchoLevel(lvlCarol)]);
|
||||
|
||||
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
|
||||
|
||||
// Đọc ĐÚNG như phiếu đang chạy đọc: theo Id đã pin + con trỏ (Bước, Cấp).
|
||||
var peRow = await h.Db.PurchaseEvaluations.AsNoTracking().FirstAsync(p => p.Id == pe.Id);
|
||||
var live = await ResolveLikeRunningPeAsync(h.Db, peRow.ApprovalWorkflowId!.Value);
|
||||
var currentStep = live.Steps.OrderBy(s => s.Order).ToList()[peRow.CurrentWorkflowStepIndex!.Value];
|
||||
var matchingRow = currentStep.Levels
|
||||
.Single(l => l.Order == peRow.CurrentApprovalLevelOrder && l.ApproverUserId == alice.Id);
|
||||
|
||||
matchingRow.AllowApproverDelete.Should().BeTrue(
|
||||
"§F.0: chỉ sửa-tại-chỗ mới đưa được cờ tới phiếu đang treo — POST version mới sinh Id khác");
|
||||
live.Version.Should().Be(1, "phiếu vẫn đọc ĐÚNG bản đang pin, không phải bản mới");
|
||||
|
||||
// Per-ROW chứ không per-Cấp: người khác trong quy trình KHÔNG tự có cờ.
|
||||
live.Steps[0].Levels.Single(l => l.ApproverUserId == carol.Id)
|
||||
.AllowApproverDelete.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// F-16 — blast radius: `ApprovalWorkflow` V2 dùng chung ≥6 module. Nếu guard
|
||||
// chữ ký chỉ soi bảng PE thì quy trình của Văn phòng số vẫn nổ FK 547 → 500.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task F16_ProposalWorkflow_RemoveApproverWithLiveProposalOpinion_Blocked409()
|
||||
{
|
||||
var h = NewHarness();
|
||||
using (h.Fix)
|
||||
{
|
||||
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
|
||||
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Bình", null, []);
|
||||
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
|
||||
|
||||
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.ProposalGeneral,
|
||||
[[(1, alice.Id), (1, bob.Id)]], code: "QT-DX-V2");
|
||||
var lvlBob = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == bob.Id);
|
||||
var lvlAlice = wf.Steps.Single().Levels.Single(l => l.ApproverUserId == alice.Id);
|
||||
|
||||
var proposal = new Proposal
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Title = "Đề xuất mua máy tính",
|
||||
Status = ProposalStatus.DaGuiDuyet,
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
CurrentApprovalLevelOrder = 1,
|
||||
};
|
||||
h.Db.Proposals.Add(proposal);
|
||||
h.Db.ProposalLevelOpinions.Add(new ProposalLevelOpinion
|
||||
{
|
||||
ProposalId = proposal.Id,
|
||||
ApprovalWorkflowLevelId = lvlBob.Id,
|
||||
Comment = "Đồng ý",
|
||||
SignedAt = h.Clock.UtcNow,
|
||||
SignedByUserId = bob.Id,
|
||||
SignedByFullName = "Bình",
|
||||
});
|
||||
await h.Db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var cmd = WithLevels(BuildEchoCommand(wf), 0, [EchoLevel(lvlAlice)]);
|
||||
var act = async () => await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>(
|
||||
"chữ ký của module Đề xuất cũng Restrict FK y như PE — guard phải quét CẢ 7 bảng opinion");
|
||||
|
||||
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
|
||||
live.Steps.Single().Levels.Should().HaveCount(2);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task F16_ProposalWorkflow_SafeUpdate_Succeeds_AndWritesNoPeChangelog()
|
||||
{
|
||||
var h = NewHarness();
|
||||
using (h.Fix)
|
||||
{
|
||||
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
|
||||
var carol = await h.Fix.CreateUserAsync("carol@t.vn", "Cúc", null, []);
|
||||
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị", null, [AppRoles.Admin]);
|
||||
|
||||
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.ProposalGeneral,
|
||||
[[(1, alice.Id)]], code: "QT-DX-V2");
|
||||
var proposal = new Proposal
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Title = "Đề xuất tăng ngân sách",
|
||||
Status = ProposalStatus.DaGuiDuyet,
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
CurrentApprovalLevelOrder = 1,
|
||||
};
|
||||
h.Db.Proposals.Add(proposal);
|
||||
await h.Db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// 🔴 CHỨNG-NHÂN cho cổng module (vá F11 — reviewer bắt ca này XANH GIẢ).
|
||||
// Nếu KHÔNG ghim phiếu PE nào vào workflow này thì `affected` rỗng ở CẢ HAI
|
||||
// thế giới — còn cổng `def.ApplicableType is DuyetNcc or DuyetNccPhuongAn`
|
||||
// hay gỡ cổng đi — nên assert `Count == 0` bên dưới luôn đúng và KHÔNG đo gì.
|
||||
// Ghim 1 phiếu PE (phase ChoDuyet = "chưa xong" ⇒ lọt truy vấn `affected`)
|
||||
// vào đúng workflow module Đề xuất này thì phép đo mới phân biệt được:
|
||||
// gỡ cổng ⇒ handler ghi changelog PE ⇒ ca này ĐỎ.
|
||||
var peGateWitness = await SeedPeAsync(h.Db, wf.Id, admin.Id, "PE-F16-GATE");
|
||||
|
||||
var lvlAlice = wf.Steps.Single().Levels.Single();
|
||||
var cmd = WithLevels(BuildEchoCommand(wf, reason: "Bổ sung người duyệt"), 0,
|
||||
[EchoLevel(lvlAlice), NewLevel(1, carol.Id)]);
|
||||
|
||||
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
|
||||
|
||||
var live = await ResolveLikeRunningPeAsync(h.Db, wf.Id);
|
||||
live.Steps.Single().Levels.Select(l => l.ApproverUserId)
|
||||
.Should().BeEquivalentTo(new[] { alice.Id, carol.Id });
|
||||
live.ApplicableType.Should().Be(ApprovalWorkflowApplicableType.ProposalGeneral);
|
||||
|
||||
// Phép đo nay CÓ RĂNG: có đúng 1 phiếu PE ChoDuyet ghim workflow này, nên
|
||||
// nếu cổng module bị gỡ thì handler sẽ ghi vết cho nó ⇒ count = 1 ⇒ ĐỎ.
|
||||
(await h.Db.PurchaseEvaluations.AsNoTracking()
|
||||
.CountAsync(p => p.ApprovalWorkflowId == wf.Id && p.Phase == PurchaseEvaluationPhase.ChoDuyet))
|
||||
.Should().Be(1, "chứng-nhân phải tồn tại, nếu không assert dưới là rỗng nghĩa");
|
||||
(await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().CountAsync())
|
||||
.Should().Be(0, "quy trình module Đề xuất KHÔNG được đẻ vết vào changelog của PE");
|
||||
peGateWitness.Should().NotBeNull();
|
||||
|
||||
var proposalRow = await h.Db.Proposals.AsNoTracking().FirstAsync(p => p.Id == proposal.Id);
|
||||
proposalRow.ApprovalWorkflowId.Should().Be(wf.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// F-1c — GHI VẾT. Spec đòi changelog khi xoá phiếu (§B4) nhưng ban đầu quên
|
||||
// đòi vết cho việc SỬA QUYỀN — bất đối xứng, review bắt. Vết phải rơi đúng
|
||||
// phiếu ĐANG CHỊU ẢNH HƯỞNG (chưa kết thúc), không rải cho phiếu đã đóng.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task F1c_SafeUpdate_LogsChangelogWithReason_OnlyToUnfinishedPes()
|
||||
{
|
||||
var h = NewHarness();
|
||||
using (h.Fix)
|
||||
{
|
||||
var alice = await h.Fix.CreateUserAsync("alice@t.vn", "An", null, []);
|
||||
var bob = await h.Fix.CreateUserAsync("bob@t.vn", "Lê Văn Bình", null, []);
|
||||
var admin = await h.Fix.CreateUserAsync("admin@t.vn", "Quản trị hệ thống", null, [AppRoles.Admin]);
|
||||
var drafter = await h.Fix.CreateUserAsync("drafter@t.vn", "Người soạn", null, [AppRoles.Drafter]);
|
||||
|
||||
var wf = await SeedWorkflowAsync(h.Db, ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
[[(1, alice.Id)]]);
|
||||
var pending = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1c-PENDING");
|
||||
var approved = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1c-DADUYET",
|
||||
phase: PurchaseEvaluationPhase.DaDuyet);
|
||||
var rejected = await SeedPeAsync(h.Db, wf.Id, drafter.Id, "PE-F1c-TUCHOI",
|
||||
phase: PurchaseEvaluationPhase.TuChoi);
|
||||
|
||||
var lvlAlice = wf.Steps.Single().Levels.Single();
|
||||
var cmd = WithLevels(BuildEchoCommand(wf, reason: "Bấm sai gói thầu"), 0,
|
||||
[EchoLevel(lvlAlice), NewLevel(1, bob.Id)]);
|
||||
|
||||
await NewHandler(h, admin).Handle(cmd, CancellationToken.None);
|
||||
|
||||
var logs = await h.Db.PurchaseEvaluationChangelogs.AsNoTracking().ToListAsync();
|
||||
logs.Select(l => l.PurchaseEvaluationId).Should().BeEquivalentTo(new[] { pending.Id },
|
||||
$"chỉ phiếu chưa kết thúc mới chịu ảnh hưởng (DaDuyet {approved.MaPhieu} + TuChoi {rejected.MaPhieu} phải bị loại)");
|
||||
|
||||
var log = logs.Single();
|
||||
log.Action.Should().Be(ChangelogAction.Update);
|
||||
log.EntityType.Should().Be(PurchaseEvaluationEntityType.Workflow);
|
||||
log.EntityId.Should().Be(wf.Id);
|
||||
log.PhaseAtChange.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
|
||||
log.UserId.Should().Be(admin.Id);
|
||||
log.UserName.Should().Be("Quản trị hệ thống");
|
||||
log.Summary.Should().Contain("giữ nguyên phiên bản");
|
||||
log.ContextNote.Should().Contain("Lý do: Bấm sai gói thầu");
|
||||
log.ContextNote.Should().Contain("THÊM NV duyệt Lê Văn Bình",
|
||||
"vết phải đọc được bằng TÊN, không phải GUID");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user