[CLAUDE] Tests: close 3 HRM coverage gaps (S45)
Gap1 (CRITICAL) Holiday composite UNIQUE (Year,Date): Create/Update guard, self-update no false-positive, soft-delete exclusion. Gap2 EmployeeSatellite: 5x FK-invariant parent guard + soft-delete + cascade semantics + EF model cascade config. Gap3 gotcha #44 authz regression: HrmConfigsController (bare [Authorize] + writes Roles=Admin) + EmployeesController (class Policy Hrm_HoSo.Read + per-action). 154 -> 181 PASS (+27, Infra 96->123). Surfaced drift (test-locked current behavior, fix pending): Holiday DB UNIQUE (Year,Date) NOT filtered by IsDeleted -> recreating on soft-deleted slot throws DbUpdateException(500) vs app-level !IsDeleted intent. Inconsistent with PE/Contract LevelOpinions filtered-unique pattern. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -87,4 +87,117 @@ public class AuthorizePolicyRegressionTests
|
||||
attr!.Policy.Should().Be("Workflows.Create",
|
||||
"PATCH user-selectable chỉ admin (Mig 25 Designer pin/unpin).");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Coverage gap #3 (MAJOR — S35/S36 backlog, closed S45 2026-06-01).
|
||||
// gotcha #44 regression cho 2 controller HRM bị MISS (chỉ ApprovalWorkflowsV2 có trước đó).
|
||||
//
|
||||
// 2 controller shape KHÁC nhau — lock đúng intent (KHÔNG ép về cùng 1 kiểu):
|
||||
// - HrmConfigsController: class [Authorize] trần (any-authenticated GET dropdown), writes Admin-role.
|
||||
// - EmployeesController: class Policy-gated "Hrm_HoSo.Read" + per-action Create/Update/Delete policy.
|
||||
// ===================================================================
|
||||
|
||||
// ---------- HrmConfigsController (class [Authorize] trần + writes Admin-role) ----------
|
||||
|
||||
[Fact]
|
||||
public void HrmConfigsController_ClassLevel_AuthorizeOnly_NoPolicy_NoRoles()
|
||||
{
|
||||
// Class-level [Authorize] trần — GET (leave-types/holidays/shifts/ot-policies) phải mở cho
|
||||
// MỌI authenticated user (dropdown master data). KHÔNG được hardcode Policy/Roles ở class-level
|
||||
// → sẽ block role không-Admin 403 silent khi GET (gotcha #44).
|
||||
var attr = GetClassLevelAuthorize(typeof(HrmConfigsController));
|
||||
|
||||
attr.Should().NotBeNull("controller phải có [Authorize] class-level chặn anonymous");
|
||||
attr!.Policy.Should().BeNull("class-level KHÔNG được hardcode Policy — GET dropdown cho mọi authenticated");
|
||||
attr.Roles.Should().BeNull("class-level KHÔNG được hardcode Roles — chỉ writes mới gắn Roles=Admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HrmConfigsController_ListHolidays_GET_InheritsClassLevel_NoActionAttr()
|
||||
{
|
||||
// GET list KHÔNG được override [Authorize(...)] action-level — mọi authenticated user
|
||||
// cần đọc cho dropdown (gotcha #44 silent 403 prevention).
|
||||
var attr = GetActionAuthorize(typeof(HrmConfigsController), nameof(HrmConfigsController.ListHolidays));
|
||||
|
||||
attr.Should().BeNull("GET ListHolidays phải inherit class-level [Authorize] (any authenticated)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HrmConfigsController_CreateHoliday_POST_RequiresAdminRole()
|
||||
{
|
||||
var attr = GetActionAuthorize(typeof(HrmConfigsController), nameof(HrmConfigsController.CreateHoliday));
|
||||
|
||||
attr.Should().NotBeNull("POST CreateHoliday phải có [Authorize(Roles = ...)] write-guard");
|
||||
attr!.Roles.Should().Be("Admin", "ghi Holiday chỉ Admin (role-based, KHÔNG policy).");
|
||||
attr.Policy.Should().BeNull("HrmConfigs writes dùng Roles=Admin, KHÔNG dùng Policy.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HrmConfigsController_UpdateHoliday_PUT_RequiresAdminRole()
|
||||
{
|
||||
var attr = GetActionAuthorize(typeof(HrmConfigsController), nameof(HrmConfigsController.UpdateHoliday));
|
||||
|
||||
attr.Should().NotBeNull("PUT UpdateHoliday phải có [Authorize(Roles = ...)] write-guard");
|
||||
attr!.Roles.Should().Be("Admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HrmConfigsController_DeleteHoliday_DELETE_RequiresAdminRole()
|
||||
{
|
||||
var attr = GetActionAuthorize(typeof(HrmConfigsController), nameof(HrmConfigsController.DeleteHoliday));
|
||||
|
||||
attr.Should().NotBeNull("DELETE DeleteHoliday phải có [Authorize(Roles = ...)] write-guard");
|
||||
attr!.Roles.Should().Be("Admin");
|
||||
}
|
||||
|
||||
// ---------- EmployeesController (class Policy-gated + per-action policy) ----------
|
||||
|
||||
[Fact]
|
||||
public void EmployeesController_ClassLevel_RequiresHrmHoSoReadPolicy()
|
||||
{
|
||||
// Class-level Policy "Hrm_HoSo.Read" — KHÁC HrmConfigs (hồ sơ NS nhạy cảm hơn dropdown).
|
||||
// Lock intent: role thiếu Read sẽ 403 (FE PermissionGuard wrap để tránh silent UX — gotcha #44).
|
||||
var attr = GetClassLevelAuthorize(typeof(EmployeesController));
|
||||
|
||||
attr.Should().NotBeNull("EmployeesController phải có [Authorize] class-level");
|
||||
attr!.Policy.Should().Be("Hrm_HoSo.Read",
|
||||
"hồ sơ nhân sự gate bằng policy Read class-level (KHÁC HrmConfigs any-authenticated).");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmployeesController_Create_POST_RequiresHrmHoSoCreatePolicy()
|
||||
{
|
||||
var attr = GetActionAuthorize(typeof(EmployeesController), nameof(EmployeesController.Create));
|
||||
|
||||
attr.Should().NotBeNull("POST Create phải override action-level policy");
|
||||
attr!.Policy.Should().Be("Hrm_HoSo.Create");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmployeesController_Update_PUT_RequiresHrmHoSoUpdatePolicy()
|
||||
{
|
||||
var attr = GetActionAuthorize(typeof(EmployeesController), nameof(EmployeesController.Update));
|
||||
|
||||
attr.Should().NotBeNull("PUT Update phải override action-level policy");
|
||||
attr!.Policy.Should().Be("Hrm_HoSo.Update");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmployeesController_Delete_DELETE_RequiresHrmHoSoDeletePolicy()
|
||||
{
|
||||
var attr = GetActionAuthorize(typeof(EmployeesController), nameof(EmployeesController.Delete));
|
||||
|
||||
attr.Should().NotBeNull("DELETE Delete phải override action-level policy");
|
||||
attr!.Policy.Should().Be("Hrm_HoSo.Delete");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmployeesController_CreateWorkHistory_Satellite_RequiresHrmHoSoCreatePolicy()
|
||||
{
|
||||
// 1 satellite representative — satellite write inherit cùng Create policy như parent.
|
||||
var attr = GetActionAuthorize(typeof(EmployeesController), nameof(EmployeesController.CreateWorkHistory));
|
||||
|
||||
attr.Should().NotBeNull("POST satellite CreateWorkHistory phải có action-level policy");
|
||||
attr!.Policy.Should().Be("Hrm_HoSo.Create");
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,262 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Hrm;
|
||||
using SolutionErp.Domain.Hrm;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// Coverage gap #2 (MAJOR — S34 backlog, closed S45 2026-06-01) — test-AFTER shipped code.
|
||||
// EmployeeSatelliteFeatures.cs — FK invariant + soft-delete + cascade semantics cho 5 satellite
|
||||
// (WorkHistory/Education/FamilyRelation/Skill/Document). Cookie-cutter 5× cùng guard.
|
||||
//
|
||||
// CHỐT theo CODE (single source of truth):
|
||||
// - Create*: guard `AnyAsync(EmployeeProfileId == req.Id && !IsDeleted)` → NotFoundException("EmployeeProfile", id)
|
||||
// khi parent không tồn tại HOẶC parent soft-deleted.
|
||||
// - Delete*: soft (IsDeleted=true + DeletedAt + DeletedBy=currentUser.UserId). Re-find sau xoá → NotFound.
|
||||
// - DeleteEmployeeProfile (EmployeeFeatures.cs:427-443) CHỈ soft-delete parent — KHÔNG loop children.
|
||||
// → satellite vẫn IsDeleted=false sau khi parent soft-deleted (behavior hiện tại, KHÔNG cascade ở app-layer).
|
||||
//
|
||||
// FK note: satellite → EmployeeProfile DeleteBehavior.Cascade (EF config), nhưng cascade chỉ fire ở
|
||||
// HARD delete. App dùng soft-delete (set flag) → EF cascade KHÔNG kích hoạt. Case #5 lock behavior này.
|
||||
// EmployeeProfile → User Cascade + UserId UNIQUE → seed parent PHẢI có User thật (qua IdentityFixture).
|
||||
//
|
||||
// AuditingInterceptor KHÔNG wire trong test fixture → Delete handler set IsDeleted thủ công trong code
|
||||
// handler (không qua interceptor) → vẫn soft-delete đúng (handler tự set flag, không gọi db.Remove).
|
||||
public class EmployeeSatelliteTests
|
||||
{
|
||||
private static readonly DateTime FixedNow = new(2026, 6, 1, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// Seed 1 EmployeeProfile (+ User vì FK Cascade Users + UNIQUE UserId). Trả entity để dùng Id.
|
||||
private static async Task<EmployeeProfile> SeedProfileAsync(
|
||||
IdentityFixture fix, TestApplicationDbContext db, string email, string code, bool isDeleted = false)
|
||||
{
|
||||
var user = await fix.CreateUserAsync(email, "NV " + code, departmentId: null, roles: Array.Empty<string>());
|
||||
var profile = new EmployeeProfile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
EmployeeCode = code,
|
||||
EmployeeStatus = EmployeeStatus.Active,
|
||||
IsDeleted = isDeleted,
|
||||
};
|
||||
db.EmployeeProfiles.Add(profile);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return profile;
|
||||
}
|
||||
|
||||
private static TestCurrentUser Actor(Guid userId)
|
||||
=> new() { UserId = userId, FullName = "Người xoá" };
|
||||
|
||||
// ===================================================================
|
||||
// Case 1: FK invariant ALL 5 Create — parent không tồn tại → NotFoundException("EmployeeProfile")
|
||||
// Cookie-cutter 5× — mỗi satellite y hệt guard.
|
||||
// ===================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task CreateWorkHistory_NonexistentParent_ThrowsNotFound()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var ghost = Guid.NewGuid();
|
||||
|
||||
var act = async () => await new CreateEmployeeWorkHistoryCommandHandler(db)
|
||||
.Handle(new CreateEmployeeWorkHistoryCommand(ghost, "Công ty X"), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>().WithMessage($"*EmployeeProfile*{ghost}*không tồn tại*");
|
||||
(await db.EmployeeWorkHistories.CountAsync()).Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateEducation_NonexistentParent_ThrowsNotFound()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var ghost = Guid.NewGuid();
|
||||
|
||||
var act = async () => await new CreateEmployeeEducationCommandHandler(db)
|
||||
.Handle(new CreateEmployeeEducationCommand(ghost, "Đại học X"), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>().WithMessage($"*EmployeeProfile*{ghost}*không tồn tại*");
|
||||
(await db.EmployeeEducations.CountAsync()).Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateFamilyRelation_NonexistentParent_ThrowsNotFound()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var ghost = Guid.NewGuid();
|
||||
|
||||
var act = async () => await new CreateEmployeeFamilyRelationCommandHandler(db)
|
||||
.Handle(new CreateEmployeeFamilyRelationCommand(ghost, "Nguyễn Văn Cha", FamilyRelationKind.Father),
|
||||
CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>().WithMessage($"*EmployeeProfile*{ghost}*không tồn tại*");
|
||||
(await db.EmployeeFamilyRelations.CountAsync()).Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSkill_NonexistentParent_ThrowsNotFound()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var ghost = Guid.NewGuid();
|
||||
|
||||
var act = async () => await new CreateEmployeeSkillCommandHandler(db)
|
||||
.Handle(new CreateEmployeeSkillCommand(ghost, SkillKind.Computer, "AutoCAD"), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>().WithMessage($"*EmployeeProfile*{ghost}*không tồn tại*");
|
||||
(await db.EmployeeSkills.CountAsync()).Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDocument_NonexistentParent_ThrowsNotFound()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var ghost = Guid.NewGuid();
|
||||
|
||||
var act = async () => await new CreateEmployeeDocumentCommandHandler(db)
|
||||
.Handle(new CreateEmployeeDocumentCommand(ghost, EmployeeDocumentType.IdCard, "cccd.pdf",
|
||||
"store/cccd.pdf", 1024, "application/pdf"), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>().WithMessage($"*EmployeeProfile*{ghost}*không tồn tại*");
|
||||
(await db.EmployeeDocuments.CountAsync()).Should().Be(0);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Case 2: Create với parent SOFT-DELETED → NotFoundException (guard !IsDeleted)
|
||||
// 1 representative (WorkHistory) đủ — cùng guard.
|
||||
// ===================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task CreateWorkHistory_SoftDeletedParent_ThrowsNotFound()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var parent = await SeedProfileAsync(fix, db, "softdel@test.local", "NV/2026/8001", isDeleted: true);
|
||||
|
||||
var act = async () => await new CreateEmployeeWorkHistoryCommandHandler(db)
|
||||
.Handle(new CreateEmployeeWorkHistoryCommand(parent.Id, "Công ty X"), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>()
|
||||
.WithMessage($"*EmployeeProfile*{parent.Id}*không tồn tại*");
|
||||
(await db.EmployeeWorkHistories.CountAsync()).Should().Be(0,
|
||||
"parent soft-deleted bị loại bởi !IsDeleted → coi như không tồn tại");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Case 3: Create happy path — persisted, EmployeeProfileId đúng. 1 representative.
|
||||
// ===================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task CreateWorkHistory_ValidParent_Persists_WithCorrectFk()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var parent = await SeedProfileAsync(fix, db, "ok@test.local", "NV/2026/8002");
|
||||
|
||||
var id = await new CreateEmployeeWorkHistoryCommandHandler(db)
|
||||
.Handle(new CreateEmployeeWorkHistoryCommand(parent.Id, "Công ty TNHH ABC",
|
||||
JobTitle: "Kỹ sư"), CancellationToken.None);
|
||||
|
||||
id.Should().NotBeEmpty();
|
||||
var entity = await db.EmployeeWorkHistories.AsNoTracking().SingleAsync(x => x.Id == id);
|
||||
entity.EmployeeProfileId.Should().Be(parent.Id);
|
||||
entity.CompanyName.Should().Be("Công ty TNHH ABC");
|
||||
entity.JobTitle.Should().Be("Kỹ sư");
|
||||
entity.IsDeleted.Should().BeFalse();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Case 4: Delete satellite → soft (IsDeleted=true + DeletedBy). Update/Delete lại → NotFound.
|
||||
// 1 representative (WorkHistory).
|
||||
// ===================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteWorkHistory_SoftDeletes_SetsDeletedBy_ThenExcludedFromReoperate()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var parent = await SeedProfileAsync(fix, db, "del@test.local", "NV/2026/8003");
|
||||
var actor = await fix.CreateUserAsync("admin-del@test.local", "Quản trị", null, new[] { "Admin" });
|
||||
|
||||
var satId = await new CreateEmployeeWorkHistoryCommandHandler(db)
|
||||
.Handle(new CreateEmployeeWorkHistoryCommand(parent.Id, "Công ty cũ"), CancellationToken.None);
|
||||
|
||||
// Delete → soft.
|
||||
await new DeleteEmployeeWorkHistoryCommandHandler(db, Actor(actor.Id))
|
||||
.Handle(new DeleteEmployeeWorkHistoryCommand(satId), CancellationToken.None);
|
||||
|
||||
var soft = await db.EmployeeWorkHistories.AsNoTracking().SingleAsync(x => x.Id == satId);
|
||||
soft.IsDeleted.Should().BeTrue("soft-delete set flag, KHÔNG physical remove");
|
||||
soft.DeletedBy.Should().Be(actor.Id, "DeletedBy = currentUser.UserId");
|
||||
soft.DeletedAt.Should().NotBeNull();
|
||||
|
||||
// Update lại Id đã soft-delete → NotFound (excluded bởi !IsDeleted).
|
||||
var updateAct = async () => await new UpdateEmployeeWorkHistoryCommandHandler(db)
|
||||
.Handle(new UpdateEmployeeWorkHistoryCommand(satId, "Đổi tên", null, null, null, null, null, null, null),
|
||||
CancellationToken.None);
|
||||
await updateAct.Should().ThrowAsync<NotFoundException>()
|
||||
.WithMessage($"*EmployeeWorkHistory*{satId}*không tồn tại*");
|
||||
|
||||
// Delete lại → NotFound.
|
||||
var deleteAgain = async () => await new DeleteEmployeeWorkHistoryCommandHandler(db, Actor(actor.Id))
|
||||
.Handle(new DeleteEmployeeWorkHistoryCommand(satId), CancellationToken.None);
|
||||
await deleteAgain.Should().ThrowAsync<NotFoundException>()
|
||||
.WithMessage($"*EmployeeWorkHistory*{satId}*không tồn tại*");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Case 5 ⭐: DeleteEmployeeProfile (soft) KHÔNG cascade satellite.
|
||||
// Behavior HIỆN TẠI: handler chỉ soft-delete parent, KHÔNG loop children.
|
||||
// EF FK Cascade chỉ fire ở HARD delete; app dùng soft-delete → child KHÔNG bị set IsDeleted.
|
||||
// (Nếu yêu cầu business là cascade → đây là gap cần REPORT, KHÔNG sửa trong test.)
|
||||
// ===================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteEmployeeProfile_SoftDelete_DoesNotCascadeSoftDeleteSatellites()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var parent = await SeedProfileAsync(fix, db, "parent-cascade@test.local", "NV/2026/8004");
|
||||
var actor = await fix.CreateUserAsync("admin-cascade@test.local", "Quản trị", null, new[] { "Admin" });
|
||||
|
||||
var satId = await new CreateEmployeeWorkHistoryCommandHandler(db)
|
||||
.Handle(new CreateEmployeeWorkHistoryCommand(parent.Id, "Công ty của NV"), CancellationToken.None);
|
||||
|
||||
// Soft-delete parent qua handler thật.
|
||||
await new DeleteEmployeeProfileCommandHandler(db, Actor(actor.Id))
|
||||
.Handle(new DeleteEmployeeProfileCommand(parent.Id), CancellationToken.None);
|
||||
|
||||
var parentReloaded = await db.EmployeeProfiles.AsNoTracking().SingleAsync(x => x.Id == parent.Id);
|
||||
parentReloaded.IsDeleted.Should().BeTrue("parent đã soft-delete");
|
||||
|
||||
// Satellite VẪN active — app-layer KHÔNG cascade soft-delete.
|
||||
var sat = await db.EmployeeWorkHistories.AsNoTracking().SingleAsync(x => x.Id == satId);
|
||||
sat.IsDeleted.Should().BeFalse(
|
||||
"behavior hiện tại: soft-delete parent KHÔNG loop children — EF cascade chỉ fire ở hard-delete");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Case 6: EF model cascade config assertion — lock schema intent (Pattern-10-style cho EF model).
|
||||
// ===================================================================
|
||||
|
||||
[Fact]
|
||||
public void EmployeeWorkHistory_ForeignKeyToProfile_IsCascadeDeleteBehavior()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
var fk = db.Model.FindEntityType(typeof(EmployeeWorkHistory))!
|
||||
.GetForeignKeys()
|
||||
.Single(f => f.PrincipalEntityType.ClrType == typeof(EmployeeProfile));
|
||||
|
||||
fk.DeleteBehavior.Should().Be(DeleteBehavior.Cascade,
|
||||
"schema intent: hard-delete EmployeeProfile cascade xoá satellite (EmployeeWorkHistoryConfiguration). " +
|
||||
"Lưu ý app-layer dùng soft-delete nên cascade này KHÔNG fire runtime (xem Case 5).");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,210 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Hrm;
|
||||
using SolutionErp.Domain.Hrm;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// Coverage gap #1 (CRITICAL — S35 backlog, closed S45 2026-06-01) — test-AFTER shipped code.
|
||||
// Holiday composite UNIQUE (Year, Date) guard trong CreateHolidayHandler / UpdateHolidayHandler
|
||||
// (HrmConfigFeatures.cs Region 2 L157-208). Handlers chỉ cần IApplicationDbContext (no ICurrentUser).
|
||||
//
|
||||
// CHỐT theo CODE (single source of truth):
|
||||
// - Conflict key = (Year, Date) AND !IsDeleted. Create: AnyAsync(Year==r.Year && Date==r.Date && !IsDeleted).
|
||||
// - Update: short-circuit guard `entity.Year != req.Year || entity.Date != req.Date` TRƯỚC khi
|
||||
// query conflict (+ loại trừ chính nó qua `Id != req.Id`). Giữ nguyên (Year,Date) → KHÔNG false-positive.
|
||||
// - DateOnly. NotFoundException("Holiday", id) khi Id không tồn tại → message "Holiday với id '...' không tồn tại."
|
||||
// - Soft-deleted row (IsDeleted=true) bị loại khỏi conflict check (app-level).
|
||||
//
|
||||
// ⚠️ DRIFT REPORT (S45 — found bởi test, KHÔNG fix prod):
|
||||
// App-level conflict check `!IsDeleted` (intend: soft-deleted slot reusable) MÂU THUẪN với
|
||||
// DB-level UNIQUE index `(Year, Date)` KHÔNG filter IsDeleted (HolidayConfiguration.cs:17 —
|
||||
// `HasIndex(new { Year, Date }).IsUnique()` plain, KHÔNG có `.HasFilter("[IsDeleted] = 0")`).
|
||||
// → Create lại 1 ngày lễ trùng (Year,Date) với 1 row đã soft-delete: handler PASS app-check
|
||||
// nhưng SaveChangesAsync ném DbUpdateException (SQLite Error 19 / SQL Server 2627) — surfacing
|
||||
// 500, KHÔNG phải ConflictException sạch hay insert thành công. Case 7 test theo BEHAVIOR THỰC.
|
||||
// So sánh: Holiday composite UNIQUE thiếu filtered-index pattern mà PE/Contract LevelOpinions +
|
||||
// các soft-delete UNIQUE khác đã áp (filtered `WHERE IsDeleted=0`). Đề xuất em main: hoặc
|
||||
// thêm `.HasFilter` cho index (cho phép reuse slot), hoặc bỏ `!IsDeleted` ở handler-check +
|
||||
// message rõ "đã tồn tại (kể cả đã xoá)". Cần migration nếu đổi index → defer decision em main.
|
||||
//
|
||||
// FK note: Holiday KHÔNG có FK ra entity khác (catalog độc lập) → seed trực tiếp, no parent.
|
||||
// AuditingInterceptor KHÔNG wire trong test fixture → set IsDeleted=true thủ công cho case soft-delete.
|
||||
public class HrmConfigHolidayTests
|
||||
{
|
||||
private static readonly DateOnly Jan1 = new(2026, 1, 1);
|
||||
|
||||
private static Holiday BuildHoliday(int year, DateOnly date, string name, bool isDeleted = false)
|
||||
=> new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Year = year,
|
||||
Date = date,
|
||||
Name = name,
|
||||
IsRecurring = false,
|
||||
IsPaid = true,
|
||||
IsActive = true,
|
||||
IsDeleted = isDeleted,
|
||||
};
|
||||
|
||||
private static async Task<Holiday> SeedHolidayAsync(
|
||||
TestApplicationDbContext db, int year, DateOnly date, string name, bool isDeleted = false)
|
||||
{
|
||||
var h = BuildHoliday(year, date, name, isDeleted);
|
||||
db.Holidays.Add(h);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return h;
|
||||
}
|
||||
|
||||
// ============ Case 1: Create duplicate (Year, Date) → ConflictException ============
|
||||
|
||||
[Fact]
|
||||
public async Task CreateHoliday_DuplicateYearDate_ThrowsConflict_NoSecondRow()
|
||||
{
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
await SeedHolidayAsync(db, 2026, Jan1, "Tết Dương lịch");
|
||||
|
||||
var act = async () => await new CreateHolidayHandler(db)
|
||||
.Handle(new CreateHolidayCommand(2026, Jan1, "Tết Dương lịch (trùng)", false, true, null),
|
||||
CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*Ngày lễ năm 2026*01/01/2026*đã tồn tại*");
|
||||
(await db.Holidays.CountAsync(x => x.Year == 2026 && x.Date == Jan1))
|
||||
.Should().Be(1, "conflict chặn trước Add — chỉ 1 row tồn tại");
|
||||
}
|
||||
|
||||
// ============ Case 2: Same Date khác Year → succeeds (Year là phần của key) ============
|
||||
|
||||
[Fact]
|
||||
public async Task CreateHoliday_SameDate_DifferentYear_Succeeds()
|
||||
{
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
await SeedHolidayAsync(db, 2026, Jan1, "Tết Dương lịch 2026");
|
||||
|
||||
// (2027, Jan 1) — cùng Date nhưng khác Year → KHÔNG conflict.
|
||||
var id = await new CreateHolidayHandler(db)
|
||||
.Handle(new CreateHolidayCommand(2027, new DateOnly(2027, 1, 1), "Tết Dương lịch 2027", true, true, null),
|
||||
CancellationToken.None);
|
||||
|
||||
id.Should().NotBeEmpty();
|
||||
(await db.Holidays.CountAsync()).Should().Be(2, "Year là phần composite key → 2 row tồn tại độc lập");
|
||||
}
|
||||
|
||||
// ============ Case 3: Update B → slot A đang chiếm → ConflictException ============
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateHoliday_ToSlotOccupiedByOther_ThrowsConflict()
|
||||
{
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var a = await SeedHolidayAsync(db, 2026, Jan1, "Ngày lễ A");
|
||||
var b = await SeedHolidayAsync(db, 2026, new DateOnly(2026, 2, 2), "Ngày lễ B");
|
||||
|
||||
// Update B → (2026, Jan 1) đang bị A chiếm → conflict.
|
||||
var act = async () => await new UpdateHolidayHandler(db)
|
||||
.Handle(new UpdateHolidayCommand(b.Id, 2026, Jan1, "Ngày lễ B đổi", false, true, true, null),
|
||||
CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*Ngày lễ năm 2026*01/01/2026*đã tồn tại*");
|
||||
|
||||
// A giữ nguyên (no mutation lọt qua).
|
||||
var aReloaded = await db.Holidays.AsNoTracking().SingleAsync(x => x.Id == a.Id);
|
||||
aReloaded.Date.Should().Be(Jan1);
|
||||
}
|
||||
|
||||
// ============ Case 4 ⭐: Update giữ nguyên (Year, Date), đổi mỗi Name → succeeds ============
|
||||
// Property tinh tế nhất: short-circuit guard tránh false-positive self-conflict.
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateHoliday_SameYearDate_ChangeNameOnly_Succeeds_NoSelfConflict()
|
||||
{
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var a = await SeedHolidayAsync(db, 2026, Jan1, "Tên cũ");
|
||||
|
||||
// Year + Date KHÔNG đổi → guard `entity.Year != req.Year || entity.Date != req.Date` = false
|
||||
// → KHÔNG chạy conflict query → KHÔNG self-conflict dù tồn tại đúng row đó.
|
||||
var act = async () => await new UpdateHolidayHandler(db)
|
||||
.Handle(new UpdateHolidayCommand(a.Id, 2026, Jan1, "Tên mới", true, false, true, "ghi chú mới"),
|
||||
CancellationToken.None);
|
||||
|
||||
await act.Should().NotThrowAsync("đổi Name khi giữ (Year,Date) KHÔNG được tính là trùng chính nó");
|
||||
|
||||
var reloaded = await db.Holidays.AsNoTracking().SingleAsync(x => x.Id == a.Id);
|
||||
reloaded.Name.Should().Be("Tên mới");
|
||||
reloaded.IsRecurring.Should().BeTrue("payload mới ghi đè field khác");
|
||||
reloaded.IsPaid.Should().BeFalse();
|
||||
reloaded.Description.Should().Be("ghi chú mới");
|
||||
}
|
||||
|
||||
// ============ Case 5: Update sang slot trống → succeeds ============
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateHoliday_ToEmptySlot_Succeeds()
|
||||
{
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var a = await SeedHolidayAsync(db, 2026, Jan1, "Ngày lễ A");
|
||||
|
||||
// (2026, Mar 3) chưa ai chiếm → move OK.
|
||||
var mar3 = new DateOnly(2026, 3, 3);
|
||||
await new UpdateHolidayHandler(db)
|
||||
.Handle(new UpdateHolidayCommand(a.Id, 2026, mar3, "Ngày lễ A dời", false, true, true, null),
|
||||
CancellationToken.None);
|
||||
|
||||
var reloaded = await db.Holidays.AsNoTracking().SingleAsync(x => x.Id == a.Id);
|
||||
reloaded.Date.Should().Be(mar3);
|
||||
reloaded.Year.Should().Be(2026);
|
||||
}
|
||||
|
||||
// ============ Case 6: Update Id không tồn tại → NotFoundException ============
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateHoliday_NonexistentId_ThrowsNotFound()
|
||||
{
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var ghost = Guid.NewGuid();
|
||||
|
||||
var act = async () => await new UpdateHolidayHandler(db)
|
||||
.Handle(new UpdateHolidayCommand(ghost, 2026, Jan1, "Không tồn tại", false, true, true, null),
|
||||
CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>()
|
||||
.WithMessage($"*Holiday*{ghost}*không tồn tại*");
|
||||
}
|
||||
|
||||
// ============ Case 7 ⚠️: Conflict check app-level loại soft-deleted, NHƯNG DB UNIQUE chặn ============
|
||||
// Test theo BEHAVIOR THỰC (single source of truth) + flag drift. Xem DRIFT REPORT header.
|
||||
//
|
||||
// Handler PASS app-check `!IsDeleted` (định reuse slot soft-deleted) → tới SaveChangesAsync thì
|
||||
// DB-level UNIQUE (Year, Date) — KHÔNG filter IsDeleted — reject → DbUpdateException (500).
|
||||
// Đây KHÔNG phải hành vi mong muốn của handler; lock lại để regression bắt nếu schema/handler đổi.
|
||||
|
||||
[Fact]
|
||||
public async Task CreateHoliday_DuplicateOfSoftDeletedRow_ThrowsDbUpdate_DueToUnfilteredUniqueIndex()
|
||||
{
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
// Seed 1 row (2026, Jan 1) đã soft-delete (IsDeleted=true).
|
||||
await SeedHolidayAsync(db, 2026, Jan1, "Lễ cũ đã xoá", isDeleted: true);
|
||||
|
||||
var act = async () => await new CreateHolidayHandler(db)
|
||||
.Handle(new CreateHolidayCommand(2026, Jan1, "Lễ mới", false, true, null), CancellationToken.None);
|
||||
|
||||
// BEHAVIOR THỰC: app-check !IsDeleted pass nhưng DB UNIQUE (Year,Date) unfiltered chặn.
|
||||
// Drift: kỳ vọng business là Succeeds HOẶC ConflictException sạch, KHÔNG phải 500.
|
||||
await act.Should().ThrowAsync<DbUpdateException>(
|
||||
"DB UNIQUE (Year,Date) KHÔNG filter IsDeleted → reject insert trùng slot soft-deleted. " +
|
||||
"Drift vs app-level !IsDeleted check — REPORT em main (xem header).");
|
||||
|
||||
// Slot vẫn chỉ có row soft-deleted gốc (insert mới bị rollback).
|
||||
(await db.Holidays.CountAsync(x => x.Year == 2026 && x.Date == Jan1))
|
||||
.Should().Be(1, "insert mới rollback — chỉ còn row soft-deleted gốc");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user