[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:
pqhuy1987
2026-05-26 18:17:59 +07:00
parent b3444a3448
commit 0605f19f57
4 changed files with 903 additions and 0 deletions

View File

@ -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;
}