Compare commits
2 Commits
dbbed1534d
...
0c5a014ebe
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c5a014ebe | |||
| 051b62bc2f |
@ -4,7 +4,8 @@ using SolutionErp.Domain.Hrm;
|
|||||||
|
|
||||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
// EF Mig 35 G-H2 (S34) — Ngày lễ. UNIQUE composite (Year, Date).
|
// EF Mig 35 G-H2 (S34) — Ngày lễ. UNIQUE composite (Year, Date) filtered WHERE IsDeleted=0
|
||||||
|
// (Mig 43 S45 — soft-deleted slot reusable, khớp app-level !IsDeleted check + pattern Catalogs/Contract/PE).
|
||||||
public class HolidayConfiguration : IEntityTypeConfiguration<Holiday>
|
public class HolidayConfiguration : IEntityTypeConfiguration<Holiday>
|
||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<Holiday> e)
|
public void Configure(EntityTypeBuilder<Holiday> e)
|
||||||
@ -14,6 +15,6 @@ public class HolidayConfiguration : IEntityTypeConfiguration<Holiday>
|
|||||||
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
||||||
e.Property(x => x.Description).HasMaxLength(500);
|
e.Property(x => x.Description).HasMaxLength(500);
|
||||||
|
|
||||||
e.HasIndex(x => new { x.Year, x.Date }).IsUnique();
|
e.HasIndex(x => new { x.Year, x.Date }).IsUnique().HasFilter("[IsDeleted] = 0");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,39 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class FilterHolidayUniqueIndexByIsDeleted : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Holidays_Year_Date",
|
||||||
|
table: "Holidays");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Holidays_Year_Date",
|
||||||
|
table: "Holidays",
|
||||||
|
columns: new[] { "Year", "Date" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[IsDeleted] = 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Holidays_Year_Date",
|
||||||
|
table: "Holidays");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Holidays_Year_Date",
|
||||||
|
table: "Holidays",
|
||||||
|
columns: new[] { "Year", "Date" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2548,7 +2548,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
|||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("Year", "Date")
|
b.HasIndex("Year", "Date")
|
||||||
.IsUnique();
|
.IsUnique()
|
||||||
|
.HasFilter("[IsDeleted] = 0");
|
||||||
|
|
||||||
b.ToTable("Holidays", (string)null);
|
b.ToTable("Holidays", (string)null);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -87,4 +87,117 @@ public class AuthorizePolicyRegressionTests
|
|||||||
attr!.Policy.Should().Be("Workflows.Create",
|
attr!.Policy.Should().Be("Workflows.Create",
|
||||||
"PATCH user-selectable chỉ admin (Mig 25 Designer pin/unpin).");
|
"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,198 @@
|
|||||||
|
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 RESOLVED (S45 Mig 43 `FilterHolidayUniqueIndexByIsDeleted`):
|
||||||
|
// App-level conflict check `!IsDeleted` (soft-deleted slot reusable) trước đây MÂU THUẪN với
|
||||||
|
// DB-level UNIQUE `(Year, Date)` KHÔNG filter → recreate trên slot soft-deleted ném DbUpdateException(500).
|
||||||
|
// Fix: HolidayConfiguration thêm `.HasFilter("[IsDeleted] = 0")` (khớp pattern Catalogs/Contract/PE).
|
||||||
|
// Giờ soft-deleted slot reusable đúng intent → Case 7 assert SUCCESS (xem dưới).
|
||||||
|
//
|
||||||
|
// 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 ✅: Create trên slot soft-deleted → SUCCESS (Mig 43 filtered unique) ============
|
||||||
|
// Sau Mig 43 (.HasFilter("[IsDeleted] = 0")): UNIQUE (Year,Date) chỉ tính row chưa xoá →
|
||||||
|
// soft-deleted slot reusable, khớp app-level !IsDeleted intent + pattern Catalogs/Contract/PE.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateHoliday_OnSoftDeletedSlot_Succeeds_FilteredUniqueAllowsReuse()
|
||||||
|
{
|
||||||
|
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 id = await new CreateHolidayHandler(db)
|
||||||
|
.Handle(new CreateHolidayCommand(2026, Jan1, "Lễ mới", false, true, null), CancellationToken.None);
|
||||||
|
|
||||||
|
id.Should().NotBeEmpty("filtered unique index không tính row đã soft-delete → slot reusable");
|
||||||
|
// 2 row tồn tại: 1 soft-deleted gốc + 1 active mới; chỉ 1 active chiếm slot.
|
||||||
|
(await db.Holidays.CountAsync(x => x.Year == 2026 && x.Date == Jan1 && !x.IsDeleted))
|
||||||
|
.Should().Be(1, "chỉ 1 row active chiếm slot");
|
||||||
|
(await db.Holidays.CountAsync(x => x.Year == 2026 && x.Date == Jan1))
|
||||||
|
.Should().Be(2, "soft-deleted gốc giữ lại cho audit + active mới");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user