[CLAUDE] Tests: Plan C B-Wrap BW1-BW7 Contract V2 test bundle +9 tests (111→120)
Plan C B-Wrap (spec D-Bis migration-todos.md lines 563-619) — Contract V2 ApproveV2Async ~227 LOC NO test cover after S29 Plan B deploy prod. Risk gotcha #48 high. Anh main S32 chốt defer dedicated session ~2h post-Phase 9 stabilize. S33 kick off cùng Plan B G-H1. 7 BW spec deterministic → 9 [Fact] method (BW6 split 3 for clean isolation): BW1 — ApproveV2 happy path Cấp 1→Cấp 2 cùng Bước advance pointer + Approval row + LevelOpinion UPSERT + LogTransition "Hoàn tất Cấp 1, sang Cấp 2" BW2 — Terminal Cấp cuối Bước cuối → DaPhatHanh + gen mã HĐ format "FLOCK01/HĐTP/SOL&BTBM/01" + clear pointers BW3 — skipToFinal F2 admin opt-in AllowApproverSkipToFinal=true Cấp 1 Bước 1 → advance lastStepIdx/lastLevelMaxOrder + prefix [Duyệt vượt cấp] BW4 — Outsider (không trong pendingLevelGroup.ApproverUserId) → ForbiddenException BW5 — CreateContractCommand pin workflow ApplicableType=DuyetNcc → exception "Workflow phải ApplicableType=Contract" (Reviewer S29 MAJOR catch) BW6a — ContractLevelOpinion duplicate composite (ContractId, LevelId) → DbUpdateException (UNIQUE Mig 33) BW6b — UPSERT pattern fetch+update → 1 row only Comment updated BW6c — Delete Contract → FK Cascade auto-delete ContractLevelOpinions BW7 — V1 fallback skipToFinal non-admin → ConflictException "skipToFinal chỉ hỗ trợ HĐ V2" Test infra dependencies: - ✅ TestApplicationDbContext SQLite reuse (Common/SqliteDbFixture.cs) - ✅ IdentityFixture reuse (UserManager + CreateUserAsync helper) - ✅ FixedDateTime reuse (deterministic clock) - ✅ NoOpNotificationService reuse - 🆕 TestCurrentUser stub ICurrentUser (configurable per-test scope) — 31 LOC - ✅ REAL ChangelogService inject TestCurrentUser - ✅ REAL ContractCodeGenerator inline (no mock needed, SqliteDbFixture enough for atomic sequence test) Verify: - dotnet build: 0 err 0 warn (2.49s) - dotnet test: **120/120 PASS** (was 111 baseline + 9 new) - Domain: 58/58 PASS - Infrastructure: 62/62 PASS (was 53 → +9 BW) Reviewer S33 verdict: **PASS** — 0 critical/major issues, 3 minor cosmetic defer-OK (CreateService helper dead code unused, TestCurrentUser null defensive C# warning shadow, BW1-4+7 vs BW6 using pattern style). 9/9 indep verify PASS in 4.7s. Spec strings exact match service source (BW1 ContextNote + BW2 Mã HĐ + BW3 ContextNote + BW4-7 exception messages). Smart Friend independence lần thứ 5 cumulative: 1. S22 #44 silent 403 — Reviewer catch 2. S25 #48 SQLite tie-break — Reviewer catch 3. S29 Plan CA password ≥12 — Reviewer catch 4. S29 Plan B ApplicableType — Reviewer catch 5. S33 Plan C BW — clean, em main+Implementer quality genuine NOT lowered Patterns applied: - Implementer Pattern 12-bis cross-module entity cookie-cutter mirror PE → Contract (proven 3× S29 + S33) - Test deterministic seeded helper SeedApproverF2WorkflowAsync mirror PurchaseEvaluationWorkflowServiceReturnModeTests.cs structure Files (4 new tests + 1 stub): - A tests/SolutionErp.Infrastructure.Tests/Common/TestCurrentUser.cs (31 LOC) - A tests/SolutionErp.Infrastructure.Tests/Services/ContractWorkflowServiceApproveV2Tests.cs - A tests/SolutionErp.Infrastructure.Tests/Application/CreateContractCommandApplicableTypeTests.cs - A tests/SolutionErp.Infrastructure.Tests/Common/ContractV2SchemaPersistenceTests.cs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,115 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.Contracts;
|
||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
using SolutionErp.Domain.Contracts;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.Master;
|
||||||
|
using SolutionErp.Infrastructure.Services;
|
||||||
|
using SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||||
|
|
||||||
|
// Plan C B-Wrap BW5 (S33) — CreateContractCommand validate ApprovalWorkflowId V2.
|
||||||
|
// Defense-in-depth guard: FE Workspace dropdown server-side filter ApplicableType=Contract(3),
|
||||||
|
// nhưng BE guard chặn attacker forge POST với PE workflow ID (ApplicableType=DuyetNcc=1
|
||||||
|
// hoặc DuyetNccPhuongAn=2).
|
||||||
|
//
|
||||||
|
// Code path: ContractFeatures.cs line 78-86 — throw ConflictException (NOT
|
||||||
|
// ValidationException như spec mention; FluentValidation chỉ rule MaximumLength
|
||||||
|
// + GreaterThanOrEqualTo, KHÔNG có rule cross-table check ApplicableType).
|
||||||
|
public class CreateContractCommandApplicableTypeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_PinApprovalWorkflowId_ApplicableType_DuyetNcc_Throws()
|
||||||
|
{
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
|
||||||
|
var drafter = await fix.CreateUserAsync("drafter-bw5@test.local", "Drafter BW5",
|
||||||
|
departmentId: null, roles: new[] { AppRoles.Drafter });
|
||||||
|
|
||||||
|
// Seed Supplier + Project (handler validate existence)
|
||||||
|
var sup = new Supplier { Id = Guid.NewGuid(), Code = "ABC", Name = "NCC ABC", Type = SupplierType.NhaThauPhu };
|
||||||
|
var proj = new Project { Id = Guid.NewGuid(), Code = "PROJ01", Name = "Dự án 01" };
|
||||||
|
db.Suppliers.Add(sup);
|
||||||
|
db.Projects.Add(proj);
|
||||||
|
|
||||||
|
// Seed PE-only workflow (ApplicableType=DuyetNcc) — attacker payload
|
||||||
|
var peOnlyWf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = "QT-PE-ONLY",
|
||||||
|
Version = 1,
|
||||||
|
Name = "PE-only workflow",
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
};
|
||||||
|
db.ApprovalWorkflows.Add(peOnlyWf);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// Wire handler 5 deps mirror prod (ContractFeatures.cs:53-58)
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc));
|
||||||
|
var notify = new NoOpNotificationServiceApp();
|
||||||
|
var currentUser = new TestCurrentUser { UserId = drafter.Id, Roles = new[] { AppRoles.Drafter } };
|
||||||
|
var changelog = new ChangelogService(db, currentUser, um);
|
||||||
|
var codeGen = new ContractCodeGenerator(db, dt);
|
||||||
|
var workflowSvc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um);
|
||||||
|
var handler = new CreateContractCommandHandler(db, currentUser, workflowSvc, codeGen, changelog);
|
||||||
|
|
||||||
|
var cmd = new CreateContractCommand(
|
||||||
|
Type: ContractType.HopDongThauPhu,
|
||||||
|
SupplierId: sup.Id,
|
||||||
|
ProjectId: proj.Id,
|
||||||
|
DepartmentId: null,
|
||||||
|
TemplateId: null,
|
||||||
|
GiaTri: 100_000_000m,
|
||||||
|
TenHopDong: "Forge attempt — PE workflow",
|
||||||
|
NoiDung: null,
|
||||||
|
BypassProcurementAndCCM: false,
|
||||||
|
DraftData: null,
|
||||||
|
BudgetId: null,
|
||||||
|
BudgetManualName: null,
|
||||||
|
BudgetManualAmount: null,
|
||||||
|
ApprovalWorkflowId: peOnlyWf.Id);
|
||||||
|
|
||||||
|
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||||
|
|
||||||
|
// Note: spec BW5 mention ValidationException — thực tế ContractFeatures.cs
|
||||||
|
// line 84 throw ConflictException ("Quy trình {Code} áp dụng cho {ApplicableType},
|
||||||
|
// không khớp với HĐ (cần ApplicableType=Contract)."). FluentValidation
|
||||||
|
// (line 38-50) chỉ rule field-level (MaximumLength, GreaterThanOrEqualTo),
|
||||||
|
// KHÔNG có rule cross-table.
|
||||||
|
await act.Should().ThrowAsync<ConflictException>()
|
||||||
|
.WithMessage("*ApplicableType=Contract*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoOp ICurrentUser-NOT-NEEDED notif — ContractWorkflowService DI dùng INotificationService
|
||||||
|
// (gửi noti drafter khi terminal). Bare-bone stub.
|
||||||
|
internal sealed class NoOpNotificationServiceApp : SolutionErp.Application.Notifications.INotificationService
|
||||||
|
{
|
||||||
|
public Task NotifyAsync(
|
||||||
|
Guid userId,
|
||||||
|
SolutionErp.Domain.Notifications.NotificationType type,
|
||||||
|
string title,
|
||||||
|
string? description = null,
|
||||||
|
string? href = null,
|
||||||
|
Guid? refId = null,
|
||||||
|
CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task NotifyManyAsync(
|
||||||
|
IEnumerable<Guid> userIds,
|
||||||
|
SolutionErp.Domain.Notifications.NotificationType type,
|
||||||
|
string title,
|
||||||
|
string? description = null,
|
||||||
|
string? href = null,
|
||||||
|
Guid? refId = null,
|
||||||
|
CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
}
|
||||||
@ -0,0 +1,238 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
using SolutionErp.Domain.Contracts;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.Master;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
|
||||||
|
// Plan C B-Wrap BW6 (S33) — ContractLevelOpinion schema persistence verification
|
||||||
|
// (Mig 33 — cookie-cutter mirror PE Mig 26).
|
||||||
|
//
|
||||||
|
// 3 assertion:
|
||||||
|
// 1. UNIQUE composite (ContractId, ApprovalWorkflowLevelId) — không cho 2 row
|
||||||
|
// cùng (HĐ, Level slot) → DbUpdateException khi cố insert duplicate.
|
||||||
|
// 2. UPSERT pattern fetch + update — Comment thay đổi, vẫn 1 row only.
|
||||||
|
// 3. FK Cascade Contract — xoá HĐ → ContractLevelOpinions auto-deleted.
|
||||||
|
//
|
||||||
|
// FK Restrict ApprovalWorkflowLevel KHÔNG test (per spec):
|
||||||
|
// - Admin xoá Level chặn nếu opinion tồn tại (data preservation guarantee)
|
||||||
|
// - Verify ngoài qua manual SQL hoặc integration test riêng.
|
||||||
|
public class ContractV2SchemaPersistenceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ContractLevelOpinion_DuplicateComposite_ThrowsDbUpdateException()
|
||||||
|
{
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
|
||||||
|
var approver = await fix.CreateUserAsync("a-bw6@test.local", "Approver BW6",
|
||||||
|
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||||
|
|
||||||
|
// Seed 1 Workflow + 1 Step + 1 Level
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = "QT-BW6-001",
|
||||||
|
Version = 1,
|
||||||
|
Name = "BW6 unique test",
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.Contract,
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
};
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowId = wf.Id,
|
||||||
|
Order = 1,
|
||||||
|
DepartmentId = null,
|
||||||
|
Name = "Bước 1",
|
||||||
|
};
|
||||||
|
var level = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = 1,
|
||||||
|
ApproverUserId = approver.Id,
|
||||||
|
};
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
db.ApprovalWorkflowSteps.Add(step);
|
||||||
|
db.ApprovalWorkflowLevels.Add(level);
|
||||||
|
|
||||||
|
// Seed Supplier + Project + Contract V2
|
||||||
|
var sup = new Supplier { Id = Guid.NewGuid(), Code = "ABC", Name = "NCC ABC", Type = SupplierType.NhaThauPhu };
|
||||||
|
var proj = new Project { Id = Guid.NewGuid(), Code = "PROJ01", Name = "Dự án 01" };
|
||||||
|
db.Suppliers.Add(sup);
|
||||||
|
db.Projects.Add(proj);
|
||||||
|
|
||||||
|
var contract = new Contract
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = ContractType.HopDongThauPhu,
|
||||||
|
Phase = ContractPhase.ChoDuyet,
|
||||||
|
SupplierId = sup.Id,
|
||||||
|
ProjectId = proj.Id,
|
||||||
|
DrafterUserId = Guid.NewGuid(),
|
||||||
|
TenHopDong = "Test BW6 unique",
|
||||||
|
GiaTri = 10_000_000m,
|
||||||
|
ApprovalWorkflowId = wf.Id,
|
||||||
|
CurrentWorkflowStepIndex = 0,
|
||||||
|
CurrentApprovalLevelOrder = 1,
|
||||||
|
};
|
||||||
|
db.Contracts.Add(contract);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// 2 ContractLevelOpinion cùng (ContractId, ApprovalWorkflowLevelId)
|
||||||
|
db.ContractLevelOpinions.Add(new ContractLevelOpinion
|
||||||
|
{
|
||||||
|
ContractId = contract.Id,
|
||||||
|
ApprovalWorkflowLevelId = level.Id,
|
||||||
|
Comment = "Ý kiến lần 1",
|
||||||
|
SignedAt = DateTime.UtcNow,
|
||||||
|
SignedByUserId = approver.Id,
|
||||||
|
SignedByFullName = "Approver BW6",
|
||||||
|
});
|
||||||
|
db.ContractLevelOpinions.Add(new ContractLevelOpinion
|
||||||
|
{
|
||||||
|
ContractId = contract.Id,
|
||||||
|
ApprovalWorkflowLevelId = level.Id,
|
||||||
|
Comment = "Ý kiến lần 2 — duplicate",
|
||||||
|
SignedAt = DateTime.UtcNow,
|
||||||
|
SignedByUserId = approver.Id,
|
||||||
|
SignedByFullName = "Approver BW6",
|
||||||
|
});
|
||||||
|
|
||||||
|
var act = async () => await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
await act.Should().ThrowAsync<DbUpdateException>(
|
||||||
|
"UNIQUE composite (ContractId, ApprovalWorkflowLevelId) — Mig 33");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContractLevelOpinion_UpsertPattern_FetchAndUpdate_KeepsSingleRow()
|
||||||
|
{
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
|
||||||
|
var approver = await fix.CreateUserAsync("a-bw6-up@test.local", "Approver BW6 UP",
|
||||||
|
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||||
|
|
||||||
|
var (wf, step, level, sup, proj, contract) = await SeedBaseAsync(db, approver.Id);
|
||||||
|
|
||||||
|
// Initial insert
|
||||||
|
db.ContractLevelOpinions.Add(new ContractLevelOpinion
|
||||||
|
{
|
||||||
|
ContractId = contract.Id,
|
||||||
|
ApprovalWorkflowLevelId = level.Id,
|
||||||
|
Comment = "Ý kiến đầu tiên",
|
||||||
|
SignedAt = DateTime.UtcNow,
|
||||||
|
SignedByUserId = approver.Id,
|
||||||
|
SignedByFullName = "Approver BW6 UP",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// UPSERT — fetch + update
|
||||||
|
var existing = await db.ContractLevelOpinions
|
||||||
|
.FirstAsync(o => o.ContractId == contract.Id && o.ApprovalWorkflowLevelId == level.Id);
|
||||||
|
existing.Comment = "Ý kiến đã được cập nhật";
|
||||||
|
existing.SignedAt = DateTime.UtcNow.AddSeconds(1);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var rows = await db.ContractLevelOpinions
|
||||||
|
.Where(o => o.ContractId == contract.Id).ToListAsync();
|
||||||
|
rows.Should().HaveCount(1, "UPSERT giữ duy nhất 1 row per (HĐ, Level)");
|
||||||
|
rows[0].Comment.Should().Be("Ý kiến đã được cập nhật");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContractLevelOpinion_FkCascade_DeleteContract_AlsoDeletesOpinions()
|
||||||
|
{
|
||||||
|
using var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
|
||||||
|
var approver = await fix.CreateUserAsync("a-bw6-cas@test.local", "Approver BW6 CAS",
|
||||||
|
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||||
|
|
||||||
|
var (wf, step, level, sup, proj, contract) = await SeedBaseAsync(db, approver.Id);
|
||||||
|
|
||||||
|
db.ContractLevelOpinions.Add(new ContractLevelOpinion
|
||||||
|
{
|
||||||
|
ContractId = contract.Id,
|
||||||
|
ApprovalWorkflowLevelId = level.Id,
|
||||||
|
Comment = "Sẽ bị wipe khi xoá HĐ",
|
||||||
|
SignedAt = DateTime.UtcNow,
|
||||||
|
SignedByUserId = approver.Id,
|
||||||
|
SignedByFullName = "Approver BW6 CAS",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var beforeCount = await db.ContractLevelOpinions
|
||||||
|
.CountAsync(o => o.ContractId == contract.Id);
|
||||||
|
beforeCount.Should().Be(1);
|
||||||
|
|
||||||
|
// Hard delete Contract — Cascade wipe opinions
|
||||||
|
db.Contracts.Remove(contract);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var afterCount = await db.ContractLevelOpinions
|
||||||
|
.CountAsync(o => o.ContractId == contract.Id);
|
||||||
|
afterCount.Should().Be(0, "FK Cascade Mig 33 — xoá HĐ wipe ContractLevelOpinions");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper seed full chain: Workflow + Step + Level + Supplier + Project + Contract.
|
||||||
|
private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep step,
|
||||||
|
ApprovalWorkflowLevel level, Supplier sup, Project proj, Contract contract)>
|
||||||
|
SeedBaseAsync(TestApplicationDbContext db, Guid approverUserId)
|
||||||
|
{
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = $"QT-BW6-{Guid.NewGuid().ToString()[..6]}",
|
||||||
|
Version = 1,
|
||||||
|
Name = "BW6 helper",
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.Contract,
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
};
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowId = wf.Id,
|
||||||
|
Order = 1,
|
||||||
|
DepartmentId = null,
|
||||||
|
Name = "Bước 1",
|
||||||
|
};
|
||||||
|
var level = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = 1,
|
||||||
|
ApproverUserId = approverUserId,
|
||||||
|
};
|
||||||
|
var sup = new Supplier { Id = Guid.NewGuid(), Code = "ABC", Name = "NCC ABC", Type = SupplierType.NhaThauPhu };
|
||||||
|
var proj = new Project { Id = Guid.NewGuid(), Code = "PROJ01", Name = "Dự án 01" };
|
||||||
|
var contract = new Contract
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = ContractType.HopDongThauPhu,
|
||||||
|
Phase = ContractPhase.ChoDuyet,
|
||||||
|
SupplierId = sup.Id,
|
||||||
|
ProjectId = proj.Id,
|
||||||
|
DrafterUserId = Guid.NewGuid(),
|
||||||
|
TenHopDong = "BW6 helper contract",
|
||||||
|
GiaTri = 10_000_000m,
|
||||||
|
ApprovalWorkflowId = wf.Id,
|
||||||
|
CurrentWorkflowStepIndex = 0,
|
||||||
|
CurrentApprovalLevelOrder = 1,
|
||||||
|
};
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
db.ApprovalWorkflowSteps.Add(step);
|
||||||
|
db.ApprovalWorkflowLevels.Add(level);
|
||||||
|
db.Suppliers.Add(sup);
|
||||||
|
db.Projects.Add(proj);
|
||||||
|
db.Contracts.Add(contract);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return (wf, step, level, sup, proj, contract);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
|
||||||
|
// Stub ICurrentUser cho tests cần inject ChangelogService (Contract V2 wire S29).
|
||||||
|
// ChangelogService resolve actor qua ICurrentUser.UserId → cần stub
|
||||||
|
// configurable per test scenario (vd BW3 admin bypass cần Roles chứa "Admin").
|
||||||
|
//
|
||||||
|
// Pattern: instance per test, set Acting* properties trước khi gọi service.
|
||||||
|
// Khác ICurrentUser prod (HttpContextCurrentUser) đọc JWT claims — test
|
||||||
|
// override trực tiếp.
|
||||||
|
public sealed class TestCurrentUser : ICurrentUser
|
||||||
|
{
|
||||||
|
public Guid? UserId { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? FullName { get; set; }
|
||||||
|
public IReadOnlyList<string> Roles { get; set; } = Array.Empty<string>();
|
||||||
|
public bool IsAuthenticated => UserId is not null;
|
||||||
|
|
||||||
|
public TestCurrentUser() { }
|
||||||
|
|
||||||
|
public TestCurrentUser(Guid userId, string? fullName = null, string? email = null, params string[] roles)
|
||||||
|
{
|
||||||
|
UserId = userId;
|
||||||
|
FullName = fullName;
|
||||||
|
Email = email;
|
||||||
|
Roles = roles ?? Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: simulate system actor (vd SLA auto-approve, DbInitializer seed).
|
||||||
|
public static TestCurrentUser System() => new();
|
||||||
|
}
|
||||||
@ -0,0 +1,518 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||||
|
using SolutionErp.Domain.Common;
|
||||||
|
using SolutionErp.Domain.Contracts;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.Master;
|
||||||
|
using SolutionErp.Infrastructure.Services;
|
||||||
|
using SolutionErp.Infrastructure.Tests.Common;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Services;
|
||||||
|
|
||||||
|
// Plan C B-Wrap (S33) — Contract V2 ApproveV2Async cookie-cutter mirror PE
|
||||||
|
// PurchaseEvaluationWorkflowServiceReturnModeTests (S23 t5). 5 [Fact] cover
|
||||||
|
// happy/terminal/skip/outsider-guard/V1-fallback.
|
||||||
|
//
|
||||||
|
// Service wire 6 deps (mirror prod):
|
||||||
|
// ContractWorkflowService(db, ContractCodeGenerator, FixedDateTime,
|
||||||
|
// NoOpNotificationService, ChangelogService(TestCurrentUser, um),
|
||||||
|
// UserManager<User>)
|
||||||
|
// Lý do dùng ChangelogService thật + TestCurrentUser stub: BW1+BW3 cần assert
|
||||||
|
// ContractChangelog row được log (summary + ContextNote). Mock service phá assertion này.
|
||||||
|
public class ContractWorkflowServiceApproveV2Tests
|
||||||
|
{
|
||||||
|
private static (ContractWorkflowService svc, IdentityFixture fix, TestApplicationDbContext db, FixedDateTime dt, TestCurrentUser currentUser)
|
||||||
|
CreateService(Guid? actorUserId = null, params string[] actorRoles)
|
||||||
|
{
|
||||||
|
var fix = new IdentityFixture();
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc));
|
||||||
|
var notify = new NoOpNotificationService();
|
||||||
|
var currentUser = new TestCurrentUser
|
||||||
|
{
|
||||||
|
UserId = actorUserId,
|
||||||
|
Roles = actorRoles ?? Array.Empty<string>(),
|
||||||
|
};
|
||||||
|
var changelog = new ChangelogService(db, currentUser, um);
|
||||||
|
var codeGen = new ContractCodeGenerator(db, dt);
|
||||||
|
var svc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um);
|
||||||
|
return (svc, fix, db, dt, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workflow setup: 1 Bước (1 Step) — 2 Cấp (2 Levels), mỗi Cấp 1 NV.
|
||||||
|
// Default Allow* = false trên Level (admin opt-in pattern Mig 29).
|
||||||
|
private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep step, ApprovalWorkflowLevel l1, ApprovalWorkflowLevel l2)>
|
||||||
|
SeedWorkflowAsync(
|
||||||
|
TestApplicationDbContext db,
|
||||||
|
Guid approver1UserId,
|
||||||
|
Guid approver2UserId,
|
||||||
|
string code = "QT-CT-001")
|
||||||
|
{
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = code,
|
||||||
|
Version = 1,
|
||||||
|
Name = "Test Contract Workflow V2",
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.Contract,
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
};
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowId = wf.Id,
|
||||||
|
Order = 1,
|
||||||
|
DepartmentId = null,
|
||||||
|
Name = "Bước 1 Phòng Kỹ Thuật",
|
||||||
|
};
|
||||||
|
var l1 = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = 1,
|
||||||
|
ApproverUserId = approver1UserId,
|
||||||
|
};
|
||||||
|
var l2 = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = 2,
|
||||||
|
ApproverUserId = approver2UserId,
|
||||||
|
};
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
db.ApprovalWorkflowSteps.Add(step);
|
||||||
|
db.ApprovalWorkflowLevels.Add(l1);
|
||||||
|
db.ApprovalWorkflowLevels.Add(l2);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return (wf, step, l1, l2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BW2: 1 Step × 1 Level — terminal sau Approve duy nhất.
|
||||||
|
private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep step, ApprovalWorkflowLevel l1)>
|
||||||
|
SeedSingleLevelWorkflowAsync(TestApplicationDbContext db, Guid approverUserId)
|
||||||
|
{
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = "QT-CT-TERMINAL",
|
||||||
|
Version = 1,
|
||||||
|
Name = "Test Single Level",
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.Contract,
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
};
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowId = wf.Id,
|
||||||
|
Order = 1,
|
||||||
|
DepartmentId = null,
|
||||||
|
Name = "Bước duy nhất",
|
||||||
|
};
|
||||||
|
var l1 = new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = 1,
|
||||||
|
ApproverUserId = approverUserId,
|
||||||
|
};
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
db.ApprovalWorkflowSteps.Add(step);
|
||||||
|
db.ApprovalWorkflowLevels.Add(l1);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return (wf, step, l1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BW3: 3 Step × 2 Level mỗi Step + slot Cấp 1 Bước 1 set AllowApproverSkipToFinal.
|
||||||
|
private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep s1, ApprovalWorkflowLevel s1l1, ApprovalWorkflowLevel s1l2, ApprovalWorkflowStep s2, ApprovalWorkflowStep s3)>
|
||||||
|
SeedMultiStepF2WorkflowAsync(
|
||||||
|
TestApplicationDbContext db,
|
||||||
|
Guid s1l1Approver,
|
||||||
|
Guid s1l2Approver,
|
||||||
|
Guid s2l1Approver,
|
||||||
|
Guid s2l2Approver,
|
||||||
|
Guid s3l1Approver,
|
||||||
|
Guid s3l2Approver,
|
||||||
|
bool allowSkipToFinalSlotS1L1 = true)
|
||||||
|
{
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = "QT-CT-F2",
|
||||||
|
Version = 1,
|
||||||
|
Name = "Test 3 Step F2 Contract",
|
||||||
|
ApplicableType = ApprovalWorkflowApplicableType.Contract,
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
};
|
||||||
|
var s1 = new ApprovalWorkflowStep { Id = Guid.NewGuid(), ApprovalWorkflowId = wf.Id, Order = 1, DepartmentId = null, Name = "Bước 1 Kỹ Thuật" };
|
||||||
|
var s2 = new ApprovalWorkflowStep { Id = Guid.NewGuid(), ApprovalWorkflowId = wf.Id, Order = 2, DepartmentId = null, Name = "Bước 2 CCM" };
|
||||||
|
var s3 = new ApprovalWorkflowStep { Id = Guid.NewGuid(), ApprovalWorkflowId = wf.Id, Order = 3, DepartmentId = null, Name = "Bước 3 GĐ" };
|
||||||
|
var s1l1 = new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = s1.Id, Order = 1, ApproverUserId = s1l1Approver, AllowApproverSkipToFinal = allowSkipToFinalSlotS1L1 };
|
||||||
|
var s1l2 = new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = s1.Id, Order = 2, ApproverUserId = s1l2Approver };
|
||||||
|
var s2l1 = new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = s2.Id, Order = 1, ApproverUserId = s2l1Approver };
|
||||||
|
var s2l2 = new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = s2.Id, Order = 2, ApproverUserId = s2l2Approver };
|
||||||
|
var s3l1 = new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = s3.Id, Order = 1, ApproverUserId = s3l1Approver };
|
||||||
|
var s3l2 = new ApprovalWorkflowLevel { Id = Guid.NewGuid(), ApprovalWorkflowStepId = s3.Id, Order = 2, ApproverUserId = s3l2Approver };
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
db.ApprovalWorkflowSteps.AddRange(s1, s2, s3);
|
||||||
|
db.ApprovalWorkflowLevels.AddRange(s1l1, s1l2, s2l1, s2l2, s3l1, s3l2);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return (wf, s1, s1l1, s1l2, s2, s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build Contract V2 wiring helper. ContractDepartmentApprovals nav collection
|
||||||
|
// mặc định empty — KHÔNG cần seed cho V2 happy path (PE workflow legacy V1 mới dùng).
|
||||||
|
private static Contract BuildContractAtStep0Level(
|
||||||
|
Guid workflowId,
|
||||||
|
Guid supplierId,
|
||||||
|
Guid projectId,
|
||||||
|
Guid drafterId,
|
||||||
|
int levelOrder = 1,
|
||||||
|
ContractType type = ContractType.HopDongThauPhu)
|
||||||
|
{
|
||||||
|
return new Contract
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = type,
|
||||||
|
Phase = ContractPhase.ChoDuyet,
|
||||||
|
SupplierId = supplierId,
|
||||||
|
ProjectId = projectId,
|
||||||
|
DrafterUserId = drafterId,
|
||||||
|
TenHopDong = "Test V2 contract",
|
||||||
|
GiaTri = 100_000_000m,
|
||||||
|
ApprovalWorkflowId = workflowId,
|
||||||
|
CurrentWorkflowStepIndex = 0,
|
||||||
|
CurrentApprovalLevelOrder = levelOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed Supplier + Project bằng Code cố định để gen mã RG-001 predictable.
|
||||||
|
private static async Task<(Supplier sup, Project proj)> SeedSupplierProjectAsync(
|
||||||
|
TestApplicationDbContext db,
|
||||||
|
string supplierCode = "BTBM",
|
||||||
|
string projectCode = "FLOCK01")
|
||||||
|
{
|
||||||
|
var sup = new Supplier
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = supplierCode,
|
||||||
|
Name = $"NCC {supplierCode}",
|
||||||
|
Type = SupplierType.NhaThauPhu,
|
||||||
|
};
|
||||||
|
var proj = new Project
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = projectCode,
|
||||||
|
Name = $"Dự án {projectCode}",
|
||||||
|
};
|
||||||
|
db.Suppliers.Add(sup);
|
||||||
|
db.Projects.Add(proj);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return (sup, proj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ BW1: Happy path step advance Cấp 1 → Cấp 2 cùng Bước ============
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV2_FirstLevel_AdvancesToSecondLevel_SameStep()
|
||||||
|
{
|
||||||
|
var fix = new IdentityFixture();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var approver1 = await fix.CreateUserAsync("a1-bw1@test.local", "Approver 1 BW1",
|
||||||
|
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||||
|
var approver2 = await fix.CreateUserAsync("a2-bw1@test.local", "Approver 2 BW1",
|
||||||
|
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||||
|
|
||||||
|
// Recreate service với actorUserId = approver1 cho ChangelogService resolve UserName đúng
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc));
|
||||||
|
var notify = new NoOpNotificationService();
|
||||||
|
var currentUser = new TestCurrentUser { UserId = approver1.Id, Roles = new[] { AppRoles.CostControl } };
|
||||||
|
var changelog = new ChangelogService(db, currentUser, um);
|
||||||
|
var codeGen = new ContractCodeGenerator(db, dt);
|
||||||
|
var svc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um);
|
||||||
|
|
||||||
|
var (wf, _, l1, _) = await SeedWorkflowAsync(db, approver1.Id, approver2.Id);
|
||||||
|
var (sup, proj) = await SeedSupplierProjectAsync(db);
|
||||||
|
var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id, drafterId: Guid.NewGuid(), levelOrder: 1);
|
||||||
|
db.Contracts.Add(contract);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await svc.TransitionAsync(
|
||||||
|
contract: contract,
|
||||||
|
targetPhase: ContractPhase.ChoDuyet,
|
||||||
|
actorUserId: approver1.Id,
|
||||||
|
actorRoles: new[] { AppRoles.CostControl },
|
||||||
|
decision: ApprovalDecision.Approve,
|
||||||
|
comment: "trải nghiệm test",
|
||||||
|
ct: CancellationToken.None);
|
||||||
|
|
||||||
|
contract.CurrentApprovalLevelOrder.Should().Be(2, "Lên Cấp 2 cùng Bước 1");
|
||||||
|
contract.CurrentWorkflowStepIndex.Should().Be(0, "Step không advance vì còn Cấp 2");
|
||||||
|
contract.Phase.Should().Be(ContractPhase.ChoDuyet, "Phase giữ ChoDuyet (chưa terminal)");
|
||||||
|
contract.SlaDeadline.Should().NotBeNull("SLA reset 7d cho Cấp 2 nhận phiếu");
|
||||||
|
|
||||||
|
var approvals = await db.ContractApprovals
|
||||||
|
.Where(a => a.ContractId == contract.Id).ToListAsync();
|
||||||
|
approvals.Should().HaveCount(1);
|
||||||
|
approvals[0].ApproverUserId.Should().Be(approver1.Id);
|
||||||
|
approvals[0].Decision.Should().Be(ApprovalDecision.Approve);
|
||||||
|
|
||||||
|
var opinions = await db.ContractLevelOpinions
|
||||||
|
.Where(o => o.ContractId == contract.Id).ToListAsync();
|
||||||
|
opinions.Should().HaveCount(1, "UPSERT 1 row cho slot Cấp 1");
|
||||||
|
opinions[0].ApprovalWorkflowLevelId.Should().Be(l1.Id);
|
||||||
|
opinions[0].Comment.Should().Be("trải nghiệm test");
|
||||||
|
opinions[0].SignedByUserId.Should().Be(approver1.Id);
|
||||||
|
|
||||||
|
var changelogs = await db.ContractChangelogs
|
||||||
|
.Where(c => c.ContractId == contract.Id
|
||||||
|
&& c.EntityType == ChangelogEntityType.Workflow).ToListAsync();
|
||||||
|
changelogs.Should().Contain(c => c.ContextNote != null
|
||||||
|
&& c.ContextNote.Contains("Hoàn tất Cấp 1, sang Cấp 2 cùng Bước 1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ BW2: Terminal Cấp cuối Bước cuối → DaPhatHanh + gen mã HĐ ============
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV2_LastLevel_FinalStep_TransitionsToDaPhatHanh_GeneratesMaHopDong()
|
||||||
|
{
|
||||||
|
var fix = new IdentityFixture();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var approver = await fix.CreateUserAsync("a-bw2@test.local", "Approver BW2",
|
||||||
|
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||||
|
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc));
|
||||||
|
var notify = new NoOpNotificationService();
|
||||||
|
var currentUser = new TestCurrentUser { UserId = approver.Id, Roles = new[] { AppRoles.CostControl } };
|
||||||
|
var changelog = new ChangelogService(db, currentUser, um);
|
||||||
|
var codeGen = new ContractCodeGenerator(db, dt);
|
||||||
|
var svc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um);
|
||||||
|
|
||||||
|
var (wf, _, l1) = await SeedSingleLevelWorkflowAsync(db, approver.Id);
|
||||||
|
var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "BTBM", projectCode: "FLOCK01");
|
||||||
|
var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id,
|
||||||
|
drafterId: Guid.NewGuid(), levelOrder: 1, type: ContractType.HopDongThauPhu);
|
||||||
|
db.Contracts.Add(contract);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await svc.TransitionAsync(
|
||||||
|
contract: contract,
|
||||||
|
targetPhase: ContractPhase.ChoDuyet,
|
||||||
|
actorUserId: approver.Id,
|
||||||
|
actorRoles: new[] { AppRoles.CostControl },
|
||||||
|
decision: ApprovalDecision.Approve,
|
||||||
|
comment: "duyệt cuối",
|
||||||
|
ct: CancellationToken.None);
|
||||||
|
|
||||||
|
contract.Phase.Should().Be(ContractPhase.DaPhatHanh, "Terminal sau Cấp cuối Bước cuối");
|
||||||
|
contract.MaHopDong.Should().NotBeNull();
|
||||||
|
contract.MaHopDong.Should().Be("FLOCK01/HĐTP/SOL&BTBM/01",
|
||||||
|
"RG-001 format ContractType.HopDongThauPhu");
|
||||||
|
contract.CurrentWorkflowStepIndex.Should().BeNull();
|
||||||
|
contract.CurrentApprovalLevelOrder.Should().BeNull();
|
||||||
|
contract.SlaDeadline.Should().BeNull();
|
||||||
|
|
||||||
|
var approvals = await db.ContractApprovals
|
||||||
|
.Where(a => a.ContractId == contract.Id).ToListAsync();
|
||||||
|
approvals.Should().HaveCount(1);
|
||||||
|
|
||||||
|
var opinions = await db.ContractLevelOpinions
|
||||||
|
.Where(o => o.ContractId == contract.Id).ToListAsync();
|
||||||
|
opinions.Should().HaveCount(1, "Final UPSERT slot Cấp 1");
|
||||||
|
opinions[0].ApprovalWorkflowLevelId.Should().Be(l1.Id);
|
||||||
|
|
||||||
|
var changelogs = await db.ContractChangelogs
|
||||||
|
.Where(c => c.ContractId == contract.Id
|
||||||
|
&& c.EntityType == ChangelogEntityType.Workflow).ToListAsync();
|
||||||
|
changelogs.Should().Contain(c => c.Summary != null
|
||||||
|
&& c.Summary.Contains("ChoDuyet") && c.Summary.Contains("DaPhatHanh"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ BW3: skipToFinal F2 admin opt-in ============
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV2_SkipToFinal_AdminTickFlag_AdvancesToLastStepLastLevel()
|
||||||
|
{
|
||||||
|
var fix = new IdentityFixture();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var userA = await fix.CreateUserAsync("usera-bw3@test.local", "User A BW3",
|
||||||
|
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||||
|
var userB = await fix.CreateUserAsync("userb-bw3@test.local", "User B BW3",
|
||||||
|
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||||
|
var userC = await fix.CreateUserAsync("userc-bw3@test.local", "User C BW3",
|
||||||
|
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||||
|
var userD = await fix.CreateUserAsync("userd-bw3@test.local", "User D BW3",
|
||||||
|
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||||
|
var userE = await fix.CreateUserAsync("usere-bw3@test.local", "User E BW3",
|
||||||
|
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||||
|
var userF = await fix.CreateUserAsync("userf-bw3@test.local", "User F BW3",
|
||||||
|
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||||
|
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc));
|
||||||
|
var notify = new NoOpNotificationService();
|
||||||
|
var currentUser = new TestCurrentUser { UserId = userA.Id, Roles = new[] { AppRoles.CostControl } };
|
||||||
|
var changelog = new ChangelogService(db, currentUser, um);
|
||||||
|
var codeGen = new ContractCodeGenerator(db, dt);
|
||||||
|
var svc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um);
|
||||||
|
|
||||||
|
var (wf, _, _, _, _, _) = await SeedMultiStepF2WorkflowAsync(
|
||||||
|
db, userA.Id, userB.Id, userC.Id, userD.Id, userE.Id, userF.Id,
|
||||||
|
allowSkipToFinalSlotS1L1: true);
|
||||||
|
var (sup, proj) = await SeedSupplierProjectAsync(db, supplierCode: "BTBM", projectCode: "FLOCK01");
|
||||||
|
var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id,
|
||||||
|
drafterId: Guid.NewGuid(), levelOrder: 1);
|
||||||
|
db.Contracts.Add(contract);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await svc.TransitionAsync(
|
||||||
|
contract: contract,
|
||||||
|
targetPhase: ContractPhase.ChoDuyet,
|
||||||
|
actorUserId: userA.Id,
|
||||||
|
actorRoles: new[] { AppRoles.CostControl },
|
||||||
|
decision: ApprovalDecision.Approve,
|
||||||
|
comment: "duyệt thẳng cấp cuối",
|
||||||
|
skipToFinal: true,
|
||||||
|
ct: CancellationToken.None);
|
||||||
|
|
||||||
|
contract.CurrentWorkflowStepIndex.Should().Be(2,
|
||||||
|
"lastStepIdx Bước 3 (index = 3-1 = 2)");
|
||||||
|
contract.CurrentApprovalLevelOrder.Should().Be(2,
|
||||||
|
"lastLevelMaxOrder Cấp 2 Bước cuối");
|
||||||
|
contract.Phase.Should().Be(ContractPhase.ChoDuyet,
|
||||||
|
"skip advance pointer KHÔNG terminal — NV cuối vẫn cần ký thật");
|
||||||
|
contract.SlaDeadline.Should().NotBeNull();
|
||||||
|
|
||||||
|
var approvals = await db.ContractApprovals
|
||||||
|
.Where(a => a.ContractId == contract.Id).ToListAsync();
|
||||||
|
approvals.Should().HaveCount(1);
|
||||||
|
approvals[0].Comment.Should().StartWith("[Duyệt vượt cấp tới Cấp cuối]",
|
||||||
|
"Prefix enrich từ ContractWorkflowService:270 khi skipToFinal=true");
|
||||||
|
|
||||||
|
var changelogs = await db.ContractChangelogs
|
||||||
|
.Where(c => c.ContractId == contract.Id
|
||||||
|
&& c.EntityType == ChangelogEntityType.Workflow).ToListAsync();
|
||||||
|
changelogs.Should().Contain(c => c.ContextNote != null
|
||||||
|
&& c.ContextNote.Contains("Approver skip thẳng tới Bước 3 Cấp 2"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ BW4: Outsider TransitionAsync(Approve) → ForbiddenException ============
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV2_OutsiderNonAdmin_ThrowsForbiddenException()
|
||||||
|
{
|
||||||
|
var fix = new IdentityFixture();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var approver1 = await fix.CreateUserAsync("a1-bw4@test.local", "Approver 1 BW4",
|
||||||
|
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||||
|
var approver2 = await fix.CreateUserAsync("a2-bw4@test.local", "Approver 2 BW4",
|
||||||
|
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||||
|
var outsider = await fix.CreateUserAsync("out-bw4@test.local", "Outsider BW4",
|
||||||
|
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||||
|
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc));
|
||||||
|
var notify = new NoOpNotificationService();
|
||||||
|
var currentUser = new TestCurrentUser { UserId = outsider.Id, Roles = new[] { AppRoles.CostControl } };
|
||||||
|
var changelog = new ChangelogService(db, currentUser, um);
|
||||||
|
var codeGen = new ContractCodeGenerator(db, dt);
|
||||||
|
var svc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um);
|
||||||
|
|
||||||
|
var (wf, _, _, _) = await SeedWorkflowAsync(db, approver1.Id, approver2.Id);
|
||||||
|
var (sup, proj) = await SeedSupplierProjectAsync(db);
|
||||||
|
var contract = BuildContractAtStep0Level(wf.Id, sup.Id, proj.Id,
|
||||||
|
drafterId: Guid.NewGuid(), levelOrder: 1);
|
||||||
|
db.Contracts.Add(contract);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var act = async () => await svc.TransitionAsync(
|
||||||
|
contract: contract,
|
||||||
|
targetPhase: ContractPhase.ChoDuyet,
|
||||||
|
actorUserId: outsider.Id,
|
||||||
|
actorRoles: new[] { AppRoles.CostControl },
|
||||||
|
decision: ApprovalDecision.Approve,
|
||||||
|
comment: "outsider thử approve",
|
||||||
|
ct: CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ForbiddenException>()
|
||||||
|
.WithMessage("*Bước 1*Cấp 1: bạn không có trong danh sách NV duyệt*");
|
||||||
|
contract.Phase.Should().Be(ContractPhase.ChoDuyet,
|
||||||
|
"Guard chặn trước mutate phase");
|
||||||
|
contract.CurrentApprovalLevelOrder.Should().Be(1, "Pointer unchanged");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ BW7: V1 fallback skipToFinal non-admin → ConflictException ============
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveV1Fallback_SkipToFinal_NonAdmin_ThrowsConflictException()
|
||||||
|
{
|
||||||
|
var fix = new IdentityFixture();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||||
|
var approver = await fix.CreateUserAsync("a-bw7@test.local", "Approver BW7",
|
||||||
|
departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||||
|
|
||||||
|
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||||
|
var dt = new FixedDateTime(new DateTime(2026, 5, 26, 0, 0, 0, DateTimeKind.Utc));
|
||||||
|
var notify = new NoOpNotificationService();
|
||||||
|
var currentUser = new TestCurrentUser { UserId = approver.Id, Roles = new[] { AppRoles.CostControl } };
|
||||||
|
var changelog = new ChangelogService(db, currentUser, um);
|
||||||
|
var codeGen = new ContractCodeGenerator(db, dt);
|
||||||
|
var svc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um);
|
||||||
|
|
||||||
|
var (sup, proj) = await SeedSupplierProjectAsync(db);
|
||||||
|
// Contract V1 legacy — ApprovalWorkflowId = null
|
||||||
|
var contract = new Contract
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Type = ContractType.HopDongThauPhu,
|
||||||
|
Phase = ContractPhase.ChoDuyet,
|
||||||
|
SupplierId = sup.Id,
|
||||||
|
ProjectId = proj.Id,
|
||||||
|
DrafterUserId = Guid.NewGuid(),
|
||||||
|
TenHopDong = "V1 legacy contract",
|
||||||
|
GiaTri = 50_000_000m,
|
||||||
|
ApprovalWorkflowId = null,
|
||||||
|
WorkflowDefinitionId = null,
|
||||||
|
CurrentWorkflowStepIndex = 0,
|
||||||
|
};
|
||||||
|
db.Contracts.Add(contract);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var act = async () => await svc.TransitionAsync(
|
||||||
|
contract: contract,
|
||||||
|
targetPhase: ContractPhase.ChoDuyet,
|
||||||
|
actorUserId: approver.Id,
|
||||||
|
actorRoles: new[] { AppRoles.CostControl },
|
||||||
|
decision: ApprovalDecision.Approve,
|
||||||
|
comment: "thử skipToFinal V1",
|
||||||
|
skipToFinal: true,
|
||||||
|
ct: CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ConflictException>()
|
||||||
|
.WithMessage("*skipToFinal chỉ hỗ trợ HĐ V2*");
|
||||||
|
contract.Phase.Should().Be(ContractPhase.ChoDuyet, "State unchanged");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user