[CLAUDE] Infra: Mig 43 filter Holiday UNIQUE (Year,Date) by IsDeleted (S45)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m19s

Fix drift surfaced by S45 Holiday coverage tests: DB UNIQUE (Year,Date) was unfiltered while handler checks !IsDeleted -> recreating a holiday on a soft-deleted slot threw DbUpdateException(500). Add .HasFilter("[IsDeleted] = 0") matching the 13x project filtered-unique pattern (Catalogs/Contract/PE/Proposal/Budget/WorkflowApps). Soft-deleted slot now reusable per app intent. Flipped Case 7 to assert success-on-reuse. 181 test PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-01 13:43:32 +07:00
parent 051b62bc2f
commit 0c5a014ebe
5 changed files with 6433 additions and 30 deletions

View File

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

View File

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

View File

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

View File

@ -17,17 +17,11 @@ namespace SolutionErp.Infrastructure.Tests.Application;
// - DateOnly. NotFoundException("Holiday", id) khi Id không tồn tại → message "Holiday với id '...' không tồn tại." // - 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). // - 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): // DRIFT RESOLVED (S45 Mig 43 `FilterHolidayUniqueIndexByIsDeleted`):
// App-level conflict check `!IsDeleted` (intend: soft-deleted slot reusable) MÂU THUẪN với // App-level conflict check `!IsDeleted` (soft-deleted slot reusable) trước đây MÂU THUẪN với
// DB-level UNIQUE index `(Year, Date)` KHÔNG filter IsDeleted (HolidayConfiguration.cs:17 — // DB-level UNIQUE `(Year, Date)` KHÔNG filter → recreate trên slot soft-deleted ném DbUpdateException(500).
// `HasIndex(new { Year, Date }).IsUnique()` plain, KHÔNG có `.HasFilter("[IsDeleted] = 0")`). // Fix: HolidayConfiguration thêm `.HasFilter("[IsDeleted] = 0")` (khớp pattern Catalogs/Contract/PE).
// → Create lại 1 ngày lễ trùng (Year,Date) với 1 row đã soft-delete: handler PASS app-check // Giờ soft-deleted slot reusable đúng intent → Case 7 assert SUCCESS (xem dưới).
// 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. // 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. // AuditingInterceptor KHÔNG wire trong test fixture → set IsDeleted=true thủ công cho case soft-delete.
@ -179,32 +173,26 @@ public class HrmConfigHolidayTests
.WithMessage($"*Holiday*{ghost}*không tồn tại*"); .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 ============ // ============ Case 7 : Create trên slot soft-deleted → SUCCESS (Mig 43 filtered unique) ============
// Test theo BEHAVIOR THỰC (single source of truth) + flag drift. Xem DRIFT REPORT header. // 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.
// 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] [Fact]
public async Task CreateHoliday_DuplicateOfSoftDeletedRow_ThrowsDbUpdate_DueToUnfilteredUniqueIndex() public async Task CreateHoliday_OnSoftDeletedSlot_Succeeds_FilteredUniqueAllowsReuse()
{ {
using var fix = new SqliteDbFixture(); using var fix = new SqliteDbFixture();
var db = fix.Db; var db = fix.Db;
// Seed 1 row (2026, Jan 1) đã soft-delete (IsDeleted=true). // Seed 1 row (2026, Jan 1) đã soft-delete (IsDeleted=true).
await SeedHolidayAsync(db, 2026, Jan1, "Lễ cũ đã xoá", isDeleted: true); await SeedHolidayAsync(db, 2026, Jan1, "Lễ cũ đã xoá", isDeleted: true);
var act = async () => await new CreateHolidayHandler(db) var id = await new CreateHolidayHandler(db)
.Handle(new CreateHolidayCommand(2026, Jan1, "Lễ mới", false, true, null), CancellationToken.None); .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. id.Should().NotBeEmpty("filtered unique index không tính row đã soft-delete → slot reusable");
// Drift: kỳ vọng business là Succeeds HOẶC ConflictException sạch, KHÔNG phải 500. // 2 row tồn tại: 1 soft-deleted gốc + 1 active mới; chỉ 1 active chiếm slot.
await act.Should().ThrowAsync<DbUpdateException>( (await db.Holidays.CountAsync(x => x.Year == 2026 && x.Date == Jan1 && !x.IsDeleted))
"DB UNIQUE (Year,Date) KHÔNG filter IsDeleted → reject insert trùng slot soft-deleted. " + .Should().Be(1, "chỉ 1 row active chiếm slot");
"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)) (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"); .Should().Be(2, "soft-deleted gốc giữ lại cho audit + active mới");
} }
} }