[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;
|
||||
}
|
||||
Reference in New Issue
Block a user