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

View File

@ -0,0 +1,144 @@
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using SolutionErp.Infrastructure.Forms;
namespace SolutionErp.Infrastructure.Tests.Forms;
// S56 #6 (2026-06-09) — DocxRenderer.cs nullable-deref guard (CS8602 fix dòng :30,:40).
// Trước đây 0 test cho DocxRenderer (form-engine render path). Cover:
// - MainDocumentPart null → InvalidOperationException("Template .docx không có MainDocumentPart")
// (file .docx hợp lệ package nhưng KHÔNG có main document part — fail-closed message rõ).
// - Happy path: template có {{placeholder}} → render thay đúng + giữ text ngoài placeholder.
// - Placeholder không có key trong data → giữ nguyên literal {{...}} (không crash).
//
// DocxRenderer.RenderAsync đọc từ ĐĨA (File.ReadAllBytes) → test ghi file .docx tạm rồi xóa.
// OpenXml 3.5.1: WordprocessingDocument.Create(path, type) tạo package RỖNG (no MainDocumentPart);
// muốn happy path phải AddMainDocumentPart() + gán Document/Body.
public class DocxRendererTests
{
// Tạo file .docx tạm có MainDocumentPart + 1 paragraph chứa text cho trước. Trả path (caller xóa).
private static string WriteTemplateWithText(string paragraphText)
{
var path = Path.Combine(Path.GetTempPath(), $"se-docx-test-{Guid.NewGuid():N}.docx");
using (var doc = WordprocessingDocument.Create(path, WordprocessingDocumentType.Document))
{
var main = doc.AddMainDocumentPart();
main.Document = new Document(
new Body(
new Paragraph(
new Run(
new Text(paragraphText) { Space = SpaceProcessingModeValues.Preserve }))));
main.Document.Save();
}
return path;
}
// Tạo file .docx tạm RỖNG (package hợp lệ nhưng KHÔNG AddMainDocumentPart) → MainDocumentPart == null.
private static string WriteTemplateWithoutMainPart()
{
var path = Path.Combine(Path.GetTempPath(), $"se-docx-nomain-{Guid.NewGuid():N}.docx");
using (WordprocessingDocument.Create(path, WordprocessingDocumentType.Document))
{
// Cố ý KHÔNG AddMainDocumentPart() — package zip hợp lệ, main part khuyết.
}
return path;
}
// Đọc lại bytes kết quả → combine toàn bộ <w:t> text để assert nội dung sau render.
private static string ExtractBodyText(byte[] docxBytes)
{
using var ms = new MemoryStream(docxBytes);
using var doc = WordprocessingDocument.Open(ms, isEditable: false);
var body = doc.MainDocumentPart?.Document?.Body;
body.Should().NotBeNull("kết quả render phải có Body hợp lệ để đọc text");
var texts = body!.Descendants<Text>().Select(t => t.Text);
return string.Concat(texts);
}
[Fact]
public async Task RenderAsync_MissingMainDocumentPart_ThrowsInvalidOperationWithClearMessage()
{
var path = WriteTemplateWithoutMainPart();
try
{
var renderer = new DocxRenderer();
var act = async () => await renderer.RenderAsync(
path,
new Dictionary<string, string?> { ["name"] = "X" },
"out.docx");
(await act.Should().ThrowAsync<InvalidOperationException>())
.WithMessage("*MainDocumentPart*", "guard fail-closed thay vì NullReferenceException mơ hồ");
}
finally
{
File.Delete(path);
}
}
[Fact]
public async Task RenderAsync_ReplacesPlaceholder_WithProvidedValue()
{
var path = WriteTemplateWithText("Xin chào {{name}}, dự án {{project}}.");
try
{
var renderer = new DocxRenderer();
var result = await renderer.RenderAsync(
path,
new Dictionary<string, string?> { ["name"] = "Anh Huy", ["project"] = "FLOCK" },
"ket-qua.docx");
result.FileName.Should().Be("ket-qua.docx");
result.ContentType.Should().Be(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document");
ExtractBodyText(result.Content).Should().Be("Xin chào Anh Huy, dự án FLOCK.",
"cả 2 placeholder được thay; text tĩnh giữ nguyên");
}
finally
{
File.Delete(path);
}
}
[Fact]
public async Task RenderAsync_UnknownPlaceholder_KeepsLiteralToken_NoCrash()
{
var path = WriteTemplateWithText("Mã: {{maHopDong}} — {{khongCoKey}}");
try
{
var renderer = new DocxRenderer();
var result = await renderer.RenderAsync(
path,
new Dictionary<string, string?> { ["maHopDong"] = "FLOCK/01/MB" },
"out.docx");
// Key có → thay; key thiếu → giữ literal {{khongCoKey}} (không ném, không rỗng hoá nhầm).
ExtractBodyText(result.Content).Should().Be("Mã: FLOCK/01/MB — {{khongCoKey}}");
}
finally
{
File.Delete(path);
}
}
[Fact]
public async Task RenderAsync_NullDataValue_ReplacesWithEmptyString()
{
var path = WriteTemplateWithText("Ghi chú:{{note}}");
try
{
var renderer = new DocxRenderer();
var result = await renderer.RenderAsync(
path,
new Dictionary<string, string?> { ["note"] = null },
"out.docx");
ExtractBodyText(result.Content).Should().Be("Ghi chú:", "value null → thay bằng chuỗi rỗng");
}
finally
{
File.Delete(path);
}
}
}