[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

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:
pqhuy1987
2026-07-27 12:36:59 +07:00
parent d745fec445
commit b1bae77c9c
13 changed files with 8604 additions and 41 deletions

View File

@ -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 đó).

View File

@ -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");
}
}
}