[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
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:
@ -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()
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user