diff --git a/tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs b/tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs index 2daf46b..fd80e66 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs @@ -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"); + } } diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/EmployeeSatelliteTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/EmployeeSatelliteTests.cs new file mode 100644 index 0000000..de76c89 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/EmployeeSatelliteTests.cs @@ -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 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()); + 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(); + 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().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(); + 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().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(); + 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().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(); + var ghost = Guid.NewGuid(); + + var act = async () => await new CreateEmployeeSkillCommandHandler(db) + .Handle(new CreateEmployeeSkillCommand(ghost, SkillKind.Computer, "AutoCAD"), CancellationToken.None); + + await act.Should().ThrowAsync().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(); + 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().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(); + 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() + .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(); + 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(); + 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() + .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() + .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(); + 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(); + + 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)."); + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/HrmConfigHolidayTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/HrmConfigHolidayTests.cs new file mode 100644 index 0000000..af1c58d --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/HrmConfigHolidayTests.cs @@ -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 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() + .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() + .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() + .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( + "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"); + } +}