[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:
pqhuy1987
2026-06-01 13:38:05 +07:00
parent dbbed1534d
commit 051b62bc2f
3 changed files with 585 additions and 0 deletions

View File

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

View File

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

View File

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