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

View File

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