[CLAUDE] Domain+App+Api+Tests+FE-Admin+FE-User: S34 Plan 3 Phase 1.5 batch 4 item
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m48s

Phase 1.5 backlog G-H1 EmployeeProfile hardening batch (Items 6+2+1+4 of 6).

Item 6 — menuKeys FE drift sync × 2 app:
- fe-admin add: Catalogs + 4 Catalog leaves + Workflows + Budgets + Bg_List/Create/Pending (10 key)
- fe-user add: Budgets + Bg_List/Create/Pending + ApprovalWorkflowsV2 + 2 AwV2 leaf + MenuVisibility + Workflows (8 key)
- Cả 2 file giờ identical mirror BE MenuKeys.cs (28 key cumulative)

Item 2 — UpdateEmployeeProfileCommand bool→bool? safe partial update:
- 3 field IsCommunistParty/IsYouthUnion/IsTradeUnion → bool?
- Handler: HasValue check, null = giữ giá trị cũ (Reviewer minor #(b) S33 fixed)
- FE không bắt buộc gửi 3 field every PUT — tránh accidental reset

Item 1 — EmployeesController per-action policy (gotcha #44 mitigation):
- Class-level [Authorize(Policy = "Hrm_HoSo.Read")] — non-admin thiếu Read → 403
- POST [Authorize(Policy = "Hrm_HoSo.Create")]
- PUT  [Authorize(Policy = "Hrm_HoSo.Update")]
- DELETE [Authorize(Policy = "Hrm_HoSo.Delete")]

Item 4 — Test bundle Phase 1.5 (+10 [Fact], baseline 120 → 130/130 PASS):
- EmployeeCodeGeneratorTests (3 [Fact]) — atomic SERIALIZABLE NV/YYYY/NNNN
  + first call + sequential increment + year boundary preserve old year
- CreateEmployeeProfileCommandTests (4 [Fact]) — Create handler edge case
  + first profile + duplicate UserId Conflict + soft-deleted Conflict-restore
  + UserNotFound NotFoundException
- ListEmployeesQueryTests (3 [Fact]) — filter + paging logic
  + status filter + departmentId filter + search by EmployeeCode partial

Implementer Case 3 test gen caught spec mismatch (allow new after soft-delete
vs throws Conflict-restore) — chose CODE source of truth + renamed test
documenting discriminator message branch. Em main verify behavior correct
(admin UX khôi phục thay vì tạo mới — explicit flow defer Phase 1.5+).

Verify:
- dotnet build PASS (2 warn DocxRenderer baseline, 0 error)
- dotnet test 130/130 PASS (58 Domain + 72 Infra = +10)
- 4 endpoint /api/employees policy wired (gotcha #44 active mitigation)
- 4 MEMORY agent updated post-spawn (CICD Run #238 + Implementer test bundle)

Deferred Phase 1.5 next batch:
- Item 3 Satellite CRUD endpoints (WorkHistory/Education/FamilyRelation/Skill/
  Document) + FE inline edit forms — heavy ~2-3h
- Item 5 UAT smoke non-admin role verify silent 403 catch — defer post-deploy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-27 13:57:08 +07:00
parent ea440da990
commit 61e9ce5b3b
9 changed files with 521 additions and 9 deletions

View File

@ -0,0 +1,120 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Domain.Hrm;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Services;
// Plan G-H1 Phase 1.5 Test Bundle 1 (S34) — EmployeeCodeGenerator atomic
// SERIALIZABLE sequence generator. Format "NV/{YYYY}/{Seq:D4}", reset per năm.
//
// Mirror PurchaseEvaluationCodeGeneratorTests + ContractCodeGeneratorTests
// pattern: SqliteDbFixture + FixedDateTime stub + direct constructor.
//
// SQLite không enforce SERIALIZABLE isolation strict (provider mapping graceful)
// — đủ test format + sequential increment + year boundary, KHÔNG đủ test race
// condition (cần SQL Server thật cho integration test riêng).
public class EmployeeCodeGeneratorTests
{
private static (EmployeeCodeGenerator gen, SqliteDbFixture fix, FixedDateTime dt)
CreateGenerator(int year = 2026)
{
var fix = new SqliteDbFixture();
var dt = new FixedDateTime(new DateTime(year, 6, 15, 0, 0, 0, DateTimeKind.Utc));
var gen = new EmployeeCodeGenerator(fix.Db, dt);
return (gen, fix, dt);
}
// ===== Spec Fact 1: First call of year — empty sequence table =====
[Fact]
public async Task GenerateAsync_FirstCallOfYear_ReturnsNV001()
{
var (gen, fix, _) = CreateGenerator(year: 2026);
using (fix)
{
// Empty sequence table — fresh DB từ EnsureCreated().
fix.Db.EmployeeCodeSequences.Should().BeEmpty();
var code = await gen.GenerateAsync();
// Format "NV/{YYYY}/{Seq:D4}" — 4-digit zero pad.
code.Should().Be("NV/2026/0001");
// DB row created với LastSeq=1.
var seq = await fix.Db.EmployeeCodeSequences
.SingleAsync(s => s.Prefix == "NV/2026");
seq.LastSeq.Should().Be(1);
}
}
// ===== Spec Fact 2: Sequential calls increment seq =====
[Fact]
public async Task GenerateAsync_SequentialCalls_IncrementSeq()
{
var (gen, fix, _) = CreateGenerator(year: 2026);
using (fix)
{
// Seed prefix "NV/2026" với LastSeq=5 — simulate 5 NV đã tạo trong năm.
fix.Db.EmployeeCodeSequences.Add(new EmployeeCodeSequence
{
Prefix = "NV/2026",
LastSeq = 5,
UpdatedAt = new DateTime(2026, 6, 14, 0, 0, 0, DateTimeKind.Utc),
});
await fix.Db.SaveChangesAsync();
var code = await gen.GenerateAsync();
// LastSeq 5 → 6 → format "NV/2026/0006".
code.Should().Be("NV/2026/0006");
var seq = await fix.Db.EmployeeCodeSequences
.SingleAsync(s => s.Prefix == "NV/2026");
seq.LastSeq.Should().Be(6);
}
}
// ===== Spec Fact 3: Year boundary — new sequence row for new year =====
[Fact]
public async Task GenerateAsync_YearBoundary_NewSequenceForNewYear()
{
// Year=2025 seeded LastSeq=999; clock moved to 2026 → expect NEW row năm
// 2026 LastSeq=1, năm 2025 row giữ nguyên (no mutation).
var fix = new SqliteDbFixture();
var dt = new FixedDateTime(new DateTime(2025, 12, 31, 23, 0, 0, DateTimeKind.Utc));
var gen = new EmployeeCodeGenerator(fix.Db, dt);
using (fix)
{
// Seed prefix "NV/2025" với LastSeq=999.
fix.Db.EmployeeCodeSequences.Add(new EmployeeCodeSequence
{
Prefix = "NV/2025",
LastSeq = 999,
UpdatedAt = new DateTime(2025, 12, 30, 0, 0, 0, DateTimeKind.Utc),
});
await fix.Db.SaveChangesAsync();
// Cross year boundary — clock → 2026.
dt.UtcNow = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var code = await gen.GenerateAsync();
// New year prefix "NV/2026" — sequence reset bắt đầu 0001.
code.Should().Be("NV/2026/0001");
// 2 row trong DB: 2025 giữ nguyên LastSeq=999, 2026 new LastSeq=1.
var rows = await fix.Db.EmployeeCodeSequences
.OrderBy(s => s.Prefix)
.ToListAsync();
rows.Should().HaveCount(2);
rows[0].Prefix.Should().Be("NV/2025");
rows[0].LastSeq.Should().Be(999); // Untouched
rows[1].Prefix.Should().Be("NV/2026");
rows[1].LastSeq.Should().Be(1);
}
}
}