[CLAUDE] App: golive harden — LeaveBalance concurrency + ItTicket authz-order + DocxRenderer + Travel/Vehicle tests
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m19s

Pre-golive verification (S56) surfaced 4 issues; all fixed + verified (228 test pass, 0 build warning).

#3 LeaveBalance lost-update (DB11 concurrency): terminal-approve deduction was an in-memory read-modify-write (UsedDays += NumDays) under a bare SaveChanges, so two concurrent terminal approvals of the same (user,type,year) lost an update. Fix: atomic server-side ExecuteUpdateAsync (UsedDays = UsedDays + n) inside an explicit Serializable transaction (matches the codegen/Proposal/TravelVehicle convention; serializes the auto-create-row race too). Exactly-once guard (Status != DaGuiDuyet) intact. No migration.

#5 ItTicket reassign existence-oracle: AssignItTicketHandler checked ticket-NotFound before the Admin-OR-dept-IT Forbidden guard. Reordered so authorization runs first -> fail-closed (a non-IT/non-admin caller gets Forbidden for any ticketId, existent or not).

#6 DocxRenderer CS8602: null-guard MainDocumentPart + Document with clear exceptions (cleared 2 build warnings -> 0).

#4 Travel/Vehicle ApproveV2: added smoke tests (Submit->Approve terminal + outsider-Forbidden) — previously zero coverage.

Tests 216 -> 228 (+12). database-agent DB-layer review PASS; em-main cross-stack review clean (reviewer workflow stage did not emit StructuredOutput -> em-main covered the cross-stack review by reading every diff). Bundles agent-memory harvest (S56).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-09 17:51:38 +07:00
parent bef582594e
commit a20cde89fb
13 changed files with 555 additions and 19 deletions

View File

@ -240,6 +240,54 @@ public class ItTicketReassignAuthzTests
}
}
// Case 5b (S56 #5): non-IT non-admin caller + ticketId KHÔNG tồn tại → vẫn Forbidden
// (KHÔNG NotFound). Chứng minh authz chạy TRƯỚC ticket lookup → fail-closed, không rò rỉ
// existence-oracle (NotFound vs Forbidden sẽ tiết lộ ticket có thật hay không).
[Fact]
public async Task AssignItTicket_NonItNonAdmin_NonexistentTicket_ThrowsForbiddenNotNotFound()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
var ktDeptId = await SeedDeptAsync(db, "KT", "Phòng Kế toán");
var caller = await fix.CreateUserAsync("kt-probe@test.local", "Ke Toan", ktDeptId, Array.Empty<string>());
// KHÔNG seed ticket — ticketId hoàn toàn ngẫu nhiên (không tồn tại).
var handler = new AssignItTicketHandler(db, AsUser(caller), clock);
var cmd = new AssignItTicketCommand(Guid.NewGuid(), Guid.NewGuid());
// Phải Forbidden, KHÔNG được NotFound (nếu NotFound → leak: caller suy ra ticket không có).
await FluentActions.Awaiting(() => handler.Handle(cmd, CancellationToken.None))
.Should().ThrowAsync<ForbiddenException>(
"authz fail-closed chạy trước lookup — non-IT nhận Forbidden cho MỌI ticketId");
}
}
// Case 5c (S56 #5): cùng caller non-IT, lần này ticket CÓ tồn tại → cũng Forbidden.
// Cặp 5b/5c chứng minh phản hồi GIỐNG NHAU (Forbidden) bất kể ticket tồn tại — không oracle.
[Fact]
public async Task AssignItTicket_NonItNonAdmin_ExistentTicket_AlsoThrowsForbidden_SameAsNonexistent()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
var ktDeptId = await SeedDeptAsync(db, "KT", "Phòng Kế toán");
var assignee = await fix.CreateUserAsync("it-ok@test.local", "IT Ok", itDeptId, Array.Empty<string>());
var caller = await fix.CreateUserAsync("kt-probe2@test.local", "Ke Toan", ktDeptId, Array.Empty<string>());
var ticketId = await SeedTicketAsync(db, caller.Id); // ticket THẬT tồn tại
var handler = new AssignItTicketHandler(db, AsUser(caller), clock);
await FluentActions.Awaiting(() => handler.Handle(new AssignItTicketCommand(ticketId, assignee.Id), CancellationToken.None))
.Should().ThrowAsync<ForbiddenException>("ticket tồn tại nhưng caller non-IT vẫn Forbidden — phản hồi không phân biệt");
var t = await db.ItTickets.AsNoTracking().FirstAsync(x => x.Id == ticketId);
t.AssignedToUserId.Should().BeNull("Forbidden → không mutate assignment");
}
}
// Case 6: Admin caller, assignee ∈ IT → success (set AssignedTo* đúng).
[Fact]
public async Task AssignItTicket_AdminCaller_AssigneeInIt_Succeeds()

View File

@ -32,6 +32,13 @@ namespace SolutionErp.Infrastructure.Tests.Application;
//
// FK note: LeaveBalance → LeaveType Restrict (LeaveBalanceConfiguration). MỌI đơn nghỉ test
// terminal PHẢI seed 1 LeaveType row + LeaveRequest.LeaveTypeId = type.Id đó.
//
// ⚠️ S56 #3 (lost-update fix) — ĐỌC LẠI BALANCE PHẢI AsNoTracking(): handler terminal nay
// increment UsedDays qua db.LeaveBalances.Where(...).ExecuteUpdateAsync(server-side, BYPASS
// change tracker). Instance bal tracked (Add ở STEP1 hoặc pre-seed cùng context) GIỮ UsedDays
// PRE-increment. Re-read mặc định trả tracked-stale → assert sai. Mọi assert post-deduction
// dùng .AsNoTracking() (hoặc ChangeTracker.Clear()) để đọc giá trị DB thật. DB end-state đúng:
// UsedDays += NumDays; row tự tạo Entitled=DaysPerYear/Used=0/Adjustment=0/Year=StartDate.Year.
public class LeaveBalanceTests
{
private static readonly DateTime FixedNow = new(2026, 5, 30, 8, 0, 0, DateTimeKind.Utc);
@ -156,11 +163,12 @@ public class LeaveBalanceTests
leave.Status.Should().Be(WorkflowAppStatus.DaDuyet);
leave.CurrentApprovalLevelOrder.Should().BeNull();
var bal = await db.LeaveBalances
// S56 #3: re-read FRESH (ExecuteUpdateAsync bypass tracker → row tracked giữ Used=0).
var bal = await db.LeaveBalances.AsNoTracking()
.SingleAsync(b => b.UserId == requester.Id && b.LeaveTypeId == type.Id);
bal.Year.Should().Be(2026, "Year = StartDate.Year");
bal.EntitledDays.Should().Be(12m, "auto-create từ LeaveType.DaysPerYear");
bal.UsedDays.Should().Be(3m, "UsedDays += NumDays");
bal.UsedDays.Should().Be(3m, "UsedDays += NumDays (đọc DB thật qua AsNoTracking)");
bal.AdjustmentDays.Should().Be(0m);
(bal.EntitledDays + bal.AdjustmentDays - bal.UsedDays).Should().Be(9m, "Remaining = 12 + 0 3");
}
@ -196,7 +204,9 @@ public class LeaveBalanceTests
.Handle(new ApproveLeaveRequestCommand(leave.Id, "ok cấp cuối"), CancellationToken.None);
leave.Status.Should().Be(WorkflowAppStatus.DaDuyet);
var balances = await db.LeaveBalances.Where(b => b.UserId == requester.Id).ToListAsync();
// S56 #3: AsNoTracking để đọc UsedDays sau server-side increment.
var balances = await db.LeaveBalances.AsNoTracking()
.Where(b => b.UserId == requester.Id).ToListAsync();
balances.Should().HaveCount(1, "chỉ 1 row, trừ đúng 1 lần ở terminal");
balances[0].UsedDays.Should().Be(4m);
}
@ -234,10 +244,12 @@ public class LeaveBalanceTests
await new ApproveLeaveRequestHandler(db, AsUser(approver), clock)
.Handle(new ApproveLeaveRequestCommand(leave.Id, "duyệt"), CancellationToken.None);
var balances = await db.LeaveBalances
// S56 #3: pre-seed row tracked + handler STEP1 re-fetch → tracked-stale Used=5. AsNoTracking
// ép đọc DB thật (server-side increment đã +2).
var balances = await db.LeaveBalances.AsNoTracking()
.Where(b => b.UserId == requester.Id && b.LeaveTypeId == type.Id && b.Year == 2026).ToListAsync();
balances.Should().HaveCount(1, "UNIQUE (User,Type,Year) — accumulate KHÔNG tạo row mới");
balances[0].UsedDays.Should().Be(7m, "5 + 2 cộng dồn");
balances[0].UsedDays.Should().Be(7m, "5 + 2 cộng dồn (đọc DB thật qua AsNoTracking)");
}
}
@ -265,12 +277,88 @@ public class LeaveBalanceTests
await act.Should().NotThrowAsync("policy allow+warn — KHÔNG chặn vượt quota");
leave.Status.Should().Be(WorkflowAppStatus.DaDuyet);
var bal = await db.LeaveBalances.SingleAsync(b => b.UserId == requester.Id && b.LeaveTypeId == type.Id);
// S56 #3: AsNoTracking đọc UsedDays sau increment server-side.
var bal = await db.LeaveBalances.AsNoTracking()
.SingleAsync(b => b.UserId == requester.Id && b.LeaveTypeId == type.Id);
bal.UsedDays.Should().Be(20m);
(bal.EntitledDays + bal.AdjustmentDays - bal.UsedDays).Should().Be(-8m, "Remaining = 12 20 = 8");
}
}
// ============ Case 4b (S56 #3): hai đơn terminal cùng (User,Type,Year) → UsedDays CỘNG DỒN ============
// Chứng minh ExecuteUpdateAsync increment server-side (b.UsedDays + NumDays) = ACCUMULATE,
// KHÔNG overwrite. Đây là invariant race-free: 2 lượt trừ phép tuần tự cộng dồn đúng tổng
// (cùng cơ chế bảo vệ lost-update khi 2 lượt duyệt cuối song song serialize qua row).
[Fact]
public async Task TwoSeparateRequests_BothTerminal_UsedDaysAccumulates_NotOverwrites()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-acc@test.local", "Requester", null, Array.Empty<string>());
var approver = await fix.CreateUserAsync("ap-acc@test.local", "Approver", null, Array.Empty<string>());
var type = await SeedLeaveTypeAsync(db, "ANNUAL", daysPerYear: 12m);
var (wf, _) = await SeedLeaveWorkflowAsync(db, approver.Id);
// Đơn 1: 3 ngày → terminal (tạo row, Used 0→3).
var leave1 = BuildLeave(requester.Id, type.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1, numDays: 3);
db.LeaveRequests.Add(leave1);
await db.SaveChangesAsync(CancellationToken.None);
await new ApproveLeaveRequestHandler(db, AsUser(approver), clock)
.Handle(new ApproveLeaveRequestCommand(leave1.Id, "đơn 1"), CancellationToken.None);
// Đơn 2: 5 ngày → terminal (cùng UNIQUE slot, Used 3→8 — accumulate qua server-side increment).
var leave2 = BuildLeave(requester.Id, type.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1, numDays: 5);
db.LeaveRequests.Add(leave2);
await db.SaveChangesAsync(CancellationToken.None);
await new ApproveLeaveRequestHandler(db, AsUser(approver), clock)
.Handle(new ApproveLeaveRequestCommand(leave2.Id, "đơn 2"), CancellationToken.None);
var balances = await db.LeaveBalances.AsNoTracking()
.Where(b => b.UserId == requester.Id && b.LeaveTypeId == type.Id && b.Year == 2026).ToListAsync();
balances.Should().HaveCount(1, "1 row duy nhất (UNIQUE User,Type,Year)");
balances[0].UsedDays.Should().Be(8m, "3 + 5 cộng dồn — increment KHÔNG ghi đè (race-free lost-update fix)");
}
}
// ============ Case 4c (S56 #3): exactly-once — re-approve đơn DaDuyet → Conflict, KHÔNG trừ lần 2 ============
// Early guard Status != DaGuiDuyet (LeaveOtApprovalFeatures.cs:296) chặn re-approve tuần tự.
// Balance KHÔNG bị trừ thêm — chốt deduction chạy đúng MỘT lần dù gọi Approve 2 lần.
[Fact]
public async Task Approve_AlreadyDaDuyet_ReApprove_ThrowsConflict_NoDoubleDeduct()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-once@test.local", "Requester", null, Array.Empty<string>());
var approver = await fix.CreateUserAsync("ap-once@test.local", "Approver", null, Array.Empty<string>());
var type = await SeedLeaveTypeAsync(db, "ANNUAL", daysPerYear: 12m);
var (wf, _) = await SeedLeaveWorkflowAsync(db, approver.Id);
var leave = BuildLeave(requester.Id, type.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1, numDays: 3);
db.LeaveRequests.Add(leave);
await db.SaveChangesAsync(CancellationToken.None);
// Lần 1: terminal → trừ 3, Status=DaDuyet.
await new ApproveLeaveRequestHandler(db, AsUser(approver), clock)
.Handle(new ApproveLeaveRequestCommand(leave.Id, "duyệt cuối"), CancellationToken.None);
leave.Status.Should().Be(WorkflowAppStatus.DaDuyet);
// Lần 2 (cùng approver, đơn đã DaDuyet) → early guard chặn → ConflictException.
var reApprove = async () => await new ApproveLeaveRequestHandler(db, AsUser(approver), clock)
.Handle(new ApproveLeaveRequestCommand(leave.Id, "duyệt lại"), CancellationToken.None);
await reApprove.Should().ThrowAsync<ConflictException>()
.WithMessage("*Đã gửi duyệt*", "guard Status != DaGuiDuyet chặn re-approve → exactly-once");
// Balance KHÔNG bị trừ lần 2 — vẫn đúng 3 (không phải 6).
var bal = await db.LeaveBalances.AsNoTracking()
.SingleAsync(b => b.UserId == requester.Id && b.LeaveTypeId == type.Id && b.Year == 2026);
bal.UsedDays.Should().Be(3m, "deduction chạy đúng 1 lần — re-approve bị guard chặn, không double-count");
}
}
// ============ Case 5a: Reject KHÔNG trừ ============
[Fact]

View File

@ -15,6 +15,12 @@ namespace SolutionErp.Infrastructure.Tests.Application;
// Critical-algo state machine + UPSERT invariant cho LeaveOtApprovalFeatures.cs
// (LeaveRequest đầy đủ 8 case + OtRequest smoke 1 case).
//
// S56 #4 (2026-06-09): + Travel + Vehicle ApproveV2 smoke (TravelVehicleApprovalFeatures.cs) —
// trước đây 0 test, cookie-cutter của Leave/Ot path. Mỗi module: Submit→Approve→DaDuyet
// (happy path) + outsider (không phải ApproverUserId của cấp, không Admin) → Forbidden.
// ApplicableType: TravelRequest=9 (prefix DT/CT) · VehicleBooking=7 (prefix DX/XE).
// Travel/Vehicle KHÔNG trừ balance → không cần seed LeaveType.
//
// Handlers là CQRS MediatR — inject IApplicationDbContext + ICurrentUser + IDateTime
// trực tiếp (không qua service). Khác ContractWorkflowServiceApproveV2Tests (service
// wire 6 dep) — đây nhẹ hơn, instantiate handler + gọi Handle().
@ -542,4 +548,213 @@ public class WorkflowAppApproveV2Tests
await act.Should().ThrowAsync<ConflictException>().WithMessage("*Nháp hoặc Trả lại*");
}
}
// =====================================================================
// S56 #4 — Travel + Vehicle ApproveV2 smoke (cookie-cutter mirror Leave/Ot)
// =====================================================================
// Seed 1 Bước × N Cấp workflow cho ApplicableType bất kỳ (Travel=9 / Vehicle=7).
private static async Task<ApprovalWorkflow> SeedWorkflowForTypeAsync(
TestApplicationDbContext db, ApprovalWorkflowApplicableType type, string code, params Guid[] approverUserIds)
{
var wf = new ApprovalWorkflow
{
Id = Guid.NewGuid(),
Code = code,
Version = 1,
Name = $"Quy trình {code}",
ApplicableType = type,
IsActive = true,
IsUserSelectable = true,
};
var step = new ApprovalWorkflowStep
{
Id = Guid.NewGuid(),
ApprovalWorkflowId = wf.Id,
Order = 1,
DepartmentId = null,
Name = "Bước 1",
};
var levels = new List<ApprovalWorkflowLevel>();
for (var i = 0; i < approverUserIds.Length; i++)
{
levels.Add(new ApprovalWorkflowLevel
{
Id = Guid.NewGuid(),
ApprovalWorkflowStepId = step.Id,
Order = i + 1,
ApproverUserId = approverUserIds[i],
});
}
db.ApprovalWorkflows.Add(wf);
db.ApprovalWorkflowSteps.Add(step);
db.ApprovalWorkflowLevels.AddRange(levels);
await db.SaveChangesAsync(CancellationToken.None);
return wf;
}
private static TravelRequest BuildTravel(Guid requesterId, Guid? workflowId)
=> new()
{
Id = Guid.NewGuid(),
RequesterUserId = requesterId,
RequesterFullName = "Người tạo",
Destination = "Hà Nội",
StartDate = FixedNow.Date,
EndDate = FixedNow.Date.AddDays(2),
NumDays = 3,
Purpose = "Khảo sát công trường",
EstimatedCost = 5_000_000m,
Status = WorkflowAppStatus.Nhap,
ApprovalWorkflowId = workflowId,
};
private static VehicleBooking BuildVehicle(Guid requesterId, Guid? workflowId)
=> new()
{
Id = Guid.NewGuid(),
RequesterUserId = requesterId,
RequesterFullName = "Người tạo",
VehicleLicense = "30A-12345",
VehicleName = "Innova 7 chỗ",
StartAt = FixedNow,
EndAt = FixedNow.AddHours(8),
Destination = "TP.HCM",
Purpose = "Đón đối tác",
DriverName = "Anh Tài",
Status = WorkflowAppStatus.Nhap,
ApprovalWorkflowId = workflowId,
};
// ---- Travel: Submit → Approve single-level → DaDuyet (happy path) ----
[Fact]
public async Task TravelRequest_Submit_Then_Approve_SingleLevel_ReachesDaDuyet()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-tr@test.local", "Requester", null, Array.Empty<string>());
var approver = await fix.CreateUserAsync("ap-tr@test.local", "Approver", null, Array.Empty<string>());
var wf = await SeedWorkflowForTypeAsync(
db, ApprovalWorkflowApplicableType.TravelRequest, "QT-TR-001", approver.Id);
var tr = BuildTravel(requester.Id, wf.Id);
db.TravelRequests.Add(tr);
await db.SaveChangesAsync(CancellationToken.None);
await new SubmitTravelRequestHandler(db, AsUser(requester), clock)
.Handle(new SubmitTravelRequestCommand(tr.Id), CancellationToken.None);
tr.Status.Should().Be(WorkflowAppStatus.DaGuiDuyet);
tr.CurrentApprovalLevelOrder.Should().Be(1);
tr.MaDonTu.Should().Be("DT/CT/2026/001", "prefix DT/CT + năm + seq D3");
await new ApproveTravelRequestHandler(db, AsUser(approver), clock)
.Handle(new ApproveTravelRequestCommand(tr.Id, "duyệt công tác"), CancellationToken.None);
tr.Status.Should().Be(WorkflowAppStatus.DaDuyet, "single-level → terminal");
tr.CurrentApprovalLevelOrder.Should().BeNull();
var opinions = await db.TravelRequestLevelOpinions.Where(o => o.TravelRequestId == tr.Id).ToListAsync();
opinions.Should().HaveCount(1);
opinions[0].Comment.Should().Be("duyệt công tác");
opinions[0].SignedByUserId.Should().Be(approver.Id);
}
}
// ---- Travel: outsider (not approver, not Admin) → Forbidden, state unchanged ----
[Fact]
public async Task TravelRequest_Approve_OutsiderNonAdmin_ThrowsForbidden_StateUnchanged()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-tr2@test.local", "Requester", null, Array.Empty<string>());
var approver = await fix.CreateUserAsync("ap-tr2@test.local", "Approver", null, Array.Empty<string>());
var outsider = await fix.CreateUserAsync("out-tr2@test.local", "Outsider", null, Array.Empty<string>());
var wf = await SeedWorkflowForTypeAsync(
db, ApprovalWorkflowApplicableType.TravelRequest, "QT-TR-002", approver.Id);
var tr = BuildTravel(requester.Id, wf.Id);
tr.Status = WorkflowAppStatus.DaGuiDuyet;
tr.CurrentApprovalLevelOrder = 1;
db.TravelRequests.Add(tr);
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await new ApproveTravelRequestHandler(db, AsUser(outsider), clock)
.Handle(new ApproveTravelRequestCommand(tr.Id, "thử duyệt"), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>().WithMessage("*Không phải người duyệt*");
tr.Status.Should().Be(WorkflowAppStatus.DaGuiDuyet, "guard chặn trước mutate");
tr.CurrentApprovalLevelOrder.Should().Be(1);
(await db.TravelRequestLevelOpinions.CountAsync(o => o.TravelRequestId == tr.Id))
.Should().Be(0, "không tạo opinion khi bị chặn");
}
}
// ---- Vehicle: Submit → Approve single-level → DaDuyet (happy path) ----
[Fact]
public async Task VehicleBooking_Submit_Then_Approve_SingleLevel_ReachesDaDuyet()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-vb@test.local", "Requester", null, Array.Empty<string>());
var approver = await fix.CreateUserAsync("ap-vb@test.local", "Approver", null, Array.Empty<string>());
var wf = await SeedWorkflowForTypeAsync(
db, ApprovalWorkflowApplicableType.VehicleBooking, "QT-VB-001", approver.Id);
var vb = BuildVehicle(requester.Id, wf.Id);
db.VehicleBookings.Add(vb);
await db.SaveChangesAsync(CancellationToken.None);
await new SubmitVehicleBookingHandler(db, AsUser(requester), clock)
.Handle(new SubmitVehicleBookingCommand(vb.Id), CancellationToken.None);
vb.Status.Should().Be(WorkflowAppStatus.DaGuiDuyet);
vb.CurrentApprovalLevelOrder.Should().Be(1);
vb.MaDonTu.Should().Be("DX/XE/2026/001", "prefix DX/XE + năm + seq D3");
await new ApproveVehicleBookingHandler(db, AsUser(approver), clock)
.Handle(new ApproveVehicleBookingCommand(vb.Id, null), CancellationToken.None);
vb.Status.Should().Be(WorkflowAppStatus.DaDuyet, "single-level → terminal");
vb.CurrentApprovalLevelOrder.Should().BeNull();
var opinions = await db.VehicleBookingLevelOpinions.Where(o => o.VehicleBookingId == vb.Id).ToListAsync();
opinions.Should().HaveCount(1);
opinions[0].Comment.Should().Be("(duyệt — không ý kiến)", "comment null → placeholder");
opinions[0].SignedByUserId.Should().Be(approver.Id);
}
}
// ---- Vehicle: outsider (not approver, not Admin) → Forbidden, state unchanged ----
[Fact]
public async Task VehicleBooking_Approve_OutsiderNonAdmin_ThrowsForbidden_StateUnchanged()
{
var (fix, db, clock) = NewCtx();
using (fix)
{
var requester = await fix.CreateUserAsync("req-vb2@test.local", "Requester", null, Array.Empty<string>());
var approver = await fix.CreateUserAsync("ap-vb2@test.local", "Approver", null, Array.Empty<string>());
var outsider = await fix.CreateUserAsync("out-vb2@test.local", "Outsider", null, Array.Empty<string>());
var wf = await SeedWorkflowForTypeAsync(
db, ApprovalWorkflowApplicableType.VehicleBooking, "QT-VB-002", approver.Id);
var vb = BuildVehicle(requester.Id, wf.Id);
vb.Status = WorkflowAppStatus.DaGuiDuyet;
vb.CurrentApprovalLevelOrder = 1;
db.VehicleBookings.Add(vb);
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await new ApproveVehicleBookingHandler(db, AsUser(outsider), clock)
.Handle(new ApproveVehicleBookingCommand(vb.Id, "thử duyệt"), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>().WithMessage("*Không phải người duyệt*");
vb.Status.Should().Be(WorkflowAppStatus.DaGuiDuyet, "guard chặn trước mutate");
vb.CurrentApprovalLevelOrder.Should().Be(1);
(await db.VehicleBookingLevelOpinions.CountAsync(o => o.VehicleBookingId == vb.Id))
.Should().Be(0, "không tạo opinion khi bị chặn");
}
}
}