[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
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:
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user