[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
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:
@ -0,0 +1,160 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Hrm;
|
||||
using SolutionErp.Application.Hrm.Services;
|
||||
using SolutionErp.Domain.Hrm;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// Plan G-H1 Phase 1.5 Test Bundle 2 (S34) — CreateEmployeeProfileCommand handler.
|
||||
// 4 [Fact] cover: success / duplicate-active / duplicate-soft-deleted / user-not-found.
|
||||
//
|
||||
// Mirror CreateContractCommandApplicableTypeTests (BW5 ConflictException pattern):
|
||||
// IdentityFixture + handler.Handle() direct invocation (skip MediatR pipeline,
|
||||
// FluentValidator chỉ field-level constraint không tham gia cross-table check).
|
||||
//
|
||||
// IMPORTANT spec mismatch documented (S34 Test Bundle):
|
||||
// - Spec Fact 3 mention "AfterSoftDelete allows new profile for same userId".
|
||||
// - Code (EmployeeFeatures.cs:158-163) check existing KHÔNG filter IsDeleted →
|
||||
// soft-deleted profile vẫn block new profile + throw với MESSAGE KHÁC
|
||||
// ("đã xoá mềm. Cần khôi phục thay vì tạo mới.").
|
||||
// - Test theo CODE thực tế (single source of truth): expect ConflictException
|
||||
// với discriminator message khác giữa 2 case (active vs soft-deleted).
|
||||
// - Em main confirm khi review test bundle: spec → code drift, code chuẩn.
|
||||
public class CreateEmployeeProfileCommandTests
|
||||
{
|
||||
private static CreateEmployeeProfileCommandHandler CreateHandler(
|
||||
IdentityFixture fix, FixedDateTime dt)
|
||||
{
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var codeGen = new EmployeeCodeGenerator(db, dt);
|
||||
return new CreateEmployeeProfileCommandHandler(db, codeGen, um);
|
||||
}
|
||||
|
||||
// ===== Spec Fact 1: First profile for user — happy path =====
|
||||
|
||||
[Fact]
|
||||
public async Task Create_FirstProfileForUser_ReturnsId_PersistsEntity()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var dt = new FixedDateTime(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
var user = await fix.CreateUserAsync("nv001@test.local", "Nguyễn Văn A",
|
||||
departmentId: null, roles: Array.Empty<string>());
|
||||
|
||||
var handler = CreateHandler(fix, dt);
|
||||
var cmd = new CreateEmployeeProfileCommand(UserId: user.Id);
|
||||
|
||||
var id = await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
// Returns Guid not empty.
|
||||
id.Should().NotBeEmpty();
|
||||
|
||||
// Persists entity với EmployeeCode "NV/2026/0001" (first call of year).
|
||||
var entity = await db.EmployeeProfiles.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
entity.Should().NotBeNull();
|
||||
entity!.UserId.Should().Be(user.Id);
|
||||
entity.EmployeeCode.Should().Be("NV/2026/0001");
|
||||
entity.EmployeeStatus.Should().Be(EmployeeStatus.Active); // Default per command record.
|
||||
entity.Nationality.Should().Be("Việt Nam"); // Default trong handler line 184.
|
||||
}
|
||||
|
||||
// ===== Spec Fact 2: Duplicate userId (active existing) — ConflictException =====
|
||||
|
||||
[Fact]
|
||||
public async Task Create_DuplicateUserId_ThrowsConflictException()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var dt = new FixedDateTime(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
var user = await fix.CreateUserAsync("nv002@test.local", "Nguyễn Văn B",
|
||||
departmentId: null, roles: Array.Empty<string>());
|
||||
|
||||
// Seed 1 active profile (KHÔNG soft-deleted).
|
||||
db.EmployeeProfiles.Add(new EmployeeProfile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
EmployeeCode = "NV/2026/9001",
|
||||
EmployeeStatus = EmployeeStatus.Active,
|
||||
IsDeleted = false,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var handler = CreateHandler(fix, dt);
|
||||
var cmd = new CreateEmployeeProfileCommand(UserId: user.Id);
|
||||
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
// Code line 162-163 throw active message — discriminator vs soft-deleted.
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*đã có hồ sơ NV*mỗi user chỉ được 1 hồ sơ*");
|
||||
}
|
||||
|
||||
// ===== Spec Fact 3: Soft-deleted existing — BLOCKED (code behavior, NOT spec) =====
|
||||
|
||||
[Fact]
|
||||
public async Task Create_AfterSoftDelete_ThrowsConflictWithRestoreMessage()
|
||||
{
|
||||
// SPEC MISMATCH: spec mention "allows new profile" — code chặn lại + throw
|
||||
// với message "đã xoá mềm. Cần khôi phục". Test theo CODE (line 158-163).
|
||||
// Em main review: behavior này CORRECT vì admin biết user từng có profile
|
||||
// → cần khôi phục thay vì tạo mới (Phase 1.5 restore flow defer).
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var dt = new FixedDateTime(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
var user = await fix.CreateUserAsync("nv003@test.local", "Nguyễn Văn C",
|
||||
departmentId: null, roles: Array.Empty<string>());
|
||||
|
||||
// Seed 1 soft-deleted profile.
|
||||
db.EmployeeProfiles.Add(new EmployeeProfile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
EmployeeCode = "NV/2026/9003",
|
||||
EmployeeStatus = EmployeeStatus.Resigned,
|
||||
IsDeleted = true,
|
||||
DeletedAt = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
DeletedBy = Guid.NewGuid(),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var handler = CreateHandler(fix, dt);
|
||||
var cmd = new CreateEmployeeProfileCommand(UserId: user.Id);
|
||||
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
// Code line 161-162 throw soft-delete message — discriminator vs active.
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*đã xoá mềm*khôi phục thay vì tạo mới*");
|
||||
}
|
||||
|
||||
// ===== Spec Fact 4: User not found — NotFoundException =====
|
||||
|
||||
[Fact]
|
||||
public async Task Create_UserNotFound_ThrowsNotFoundException()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var dt = new FixedDateTime(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
var handler = CreateHandler(fix, dt);
|
||||
var randomUserId = Guid.NewGuid(); // KHÔNG có trong Users table.
|
||||
var cmd = new CreateEmployeeProfileCommand(UserId: randomUserId);
|
||||
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
// Code line 153-154 throw NotFoundException qua userManager.FindByIdAsync null.
|
||||
await act.Should().ThrowAsync<NotFoundException>()
|
||||
.WithMessage($"*User*{randomUserId}*không tồn tại*");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,186 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Hrm;
|
||||
using SolutionErp.Domain.Hrm;
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// Plan G-H1 Phase 1.5 Test Bundle 3 (S34) — ListEmployeesQuery filter + paging.
|
||||
// 3 [Fact] cover: status filter / department filter / search by code.
|
||||
//
|
||||
// Mirror existing List query test pattern. JOIN Users + Departments LEFT (per
|
||||
// EmployeeFeatures.cs:571-575) → seed cả Department entity + User.DepartmentId
|
||||
// để test department filter chuẩn xác.
|
||||
//
|
||||
// Search: EmployeeCode.Contains(s) || FullName.Contains(s) — use "0001" để match
|
||||
// chính xác 1 row (avoid Contains("000") match cả 0010).
|
||||
public class ListEmployeesQueryTests
|
||||
{
|
||||
private static ListEmployeesQueryHandler CreateHandler(IdentityFixture fix)
|
||||
{
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
return new ListEmployeesQueryHandler(db);
|
||||
}
|
||||
|
||||
// ===== Spec Fact 1: Filter by status — only matching =====
|
||||
|
||||
[Fact]
|
||||
public async Task List_FilterByStatus_ReturnsOnlyMatching()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
// Seed 3 user + 3 profile: 2 Active + 1 Resigned.
|
||||
var user1 = await fix.CreateUserAsync("nv-active1@test.local", "Active 1",
|
||||
departmentId: null, roles: Array.Empty<string>());
|
||||
var user2 = await fix.CreateUserAsync("nv-active2@test.local", "Active 2",
|
||||
departmentId: null, roles: Array.Empty<string>());
|
||||
var user3 = await fix.CreateUserAsync("nv-resigned@test.local", "Resigned",
|
||||
departmentId: null, roles: Array.Empty<string>());
|
||||
|
||||
db.EmployeeProfiles.Add(new EmployeeProfile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user1.Id,
|
||||
EmployeeCode = "NV/2026/0001",
|
||||
EmployeeStatus = EmployeeStatus.Active,
|
||||
});
|
||||
db.EmployeeProfiles.Add(new EmployeeProfile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user2.Id,
|
||||
EmployeeCode = "NV/2026/0002",
|
||||
EmployeeStatus = EmployeeStatus.Active,
|
||||
});
|
||||
db.EmployeeProfiles.Add(new EmployeeProfile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user3.Id,
|
||||
EmployeeCode = "NV/2026/0003",
|
||||
EmployeeStatus = EmployeeStatus.Resigned,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var handler = CreateHandler(fix);
|
||||
var query = new ListEmployeesQuery(Status: EmployeeStatus.Resigned);
|
||||
|
||||
var result = await handler.Handle(query, CancellationToken.None);
|
||||
|
||||
// Chỉ 1 row Resigned match.
|
||||
result.Items.Should().HaveCount(1);
|
||||
result.Items[0].Status.Should().Be(EmployeeStatus.Resigned);
|
||||
result.Items[0].EmployeeCode.Should().Be("NV/2026/0003");
|
||||
result.Total.Should().Be(1);
|
||||
}
|
||||
|
||||
// ===== Spec Fact 2: Filter by departmentId — only matching =====
|
||||
|
||||
[Fact]
|
||||
public async Task List_FilterByDepartmentId_ReturnsOnlyMatching()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
// Seed 2 department (A, B).
|
||||
var deptA = new Department { Id = Guid.NewGuid(), Code = "DPT-A", Name = "Phòng A" };
|
||||
var deptB = new Department { Id = Guid.NewGuid(), Code = "DPT-B", Name = "Phòng B" };
|
||||
db.Departments.Add(deptA);
|
||||
db.Departments.Add(deptB);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Seed 3 user: 2 trong A + 1 trong B.
|
||||
var userA1 = await fix.CreateUserAsync("nv-a1@test.local", "User A1",
|
||||
departmentId: deptA.Id, roles: Array.Empty<string>());
|
||||
var userA2 = await fix.CreateUserAsync("nv-a2@test.local", "User A2",
|
||||
departmentId: deptA.Id, roles: Array.Empty<string>());
|
||||
var userB1 = await fix.CreateUserAsync("nv-b1@test.local", "User B1",
|
||||
departmentId: deptB.Id, roles: Array.Empty<string>());
|
||||
|
||||
// Seed 3 EmployeeProfile.
|
||||
db.EmployeeProfiles.Add(new EmployeeProfile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userA1.Id,
|
||||
EmployeeCode = "NV/2026/0001",
|
||||
EmployeeStatus = EmployeeStatus.Active,
|
||||
});
|
||||
db.EmployeeProfiles.Add(new EmployeeProfile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userA2.Id,
|
||||
EmployeeCode = "NV/2026/0002",
|
||||
EmployeeStatus = EmployeeStatus.Active,
|
||||
});
|
||||
db.EmployeeProfiles.Add(new EmployeeProfile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userB1.Id,
|
||||
EmployeeCode = "NV/2026/0003",
|
||||
EmployeeStatus = EmployeeStatus.Active,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var handler = CreateHandler(fix);
|
||||
var query = new ListEmployeesQuery(DepartmentId: deptA.Id);
|
||||
|
||||
var result = await handler.Handle(query, CancellationToken.None);
|
||||
|
||||
// 2 user trong dept A — chính xác 2 row.
|
||||
result.Items.Should().HaveCount(2);
|
||||
result.Items.Should().OnlyContain(x => x.DepartmentId == deptA.Id);
|
||||
result.Items.Should().OnlyContain(x => x.DepartmentName == "Phòng A");
|
||||
result.Total.Should().Be(2);
|
||||
}
|
||||
|
||||
// ===== Spec Fact 3: Search by partial EmployeeCode — distinguishing match =====
|
||||
|
||||
[Fact]
|
||||
public async Task List_SearchByCode_MatchesPartialEmployeeCode()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
// Seed 3 profile với code 0001 / 0002 / 0010.
|
||||
var user1 = await fix.CreateUserAsync("nv-s1@test.local", "User Search 1",
|
||||
departmentId: null, roles: Array.Empty<string>());
|
||||
var user2 = await fix.CreateUserAsync("nv-s2@test.local", "User Search 2",
|
||||
departmentId: null, roles: Array.Empty<string>());
|
||||
var user3 = await fix.CreateUserAsync("nv-s10@test.local", "User Search 10",
|
||||
departmentId: null, roles: Array.Empty<string>());
|
||||
|
||||
db.EmployeeProfiles.Add(new EmployeeProfile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user1.Id,
|
||||
EmployeeCode = "NV/2026/0001",
|
||||
EmployeeStatus = EmployeeStatus.Active,
|
||||
});
|
||||
db.EmployeeProfiles.Add(new EmployeeProfile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user2.Id,
|
||||
EmployeeCode = "NV/2026/0002",
|
||||
EmployeeStatus = EmployeeStatus.Active,
|
||||
});
|
||||
db.EmployeeProfiles.Add(new EmployeeProfile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user3.Id,
|
||||
EmployeeCode = "NV/2026/0010",
|
||||
EmployeeStatus = EmployeeStatus.Active,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var handler = CreateHandler(fix);
|
||||
// Search "0001" → unique match chỉ "NV/2026/0001" (avoid Contains("000")
|
||||
// match cả 0010 — per spec Adjust note).
|
||||
var query = new ListEmployeesQuery() { Search = "0001" };
|
||||
|
||||
var result = await handler.Handle(query, CancellationToken.None);
|
||||
|
||||
result.Items.Should().HaveCount(1);
|
||||
result.Items[0].EmployeeCode.Should().Be("NV/2026/0001");
|
||||
result.Total.Should().Be(1);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user