[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

@ -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."
// - 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.
// 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.
@ -179,32 +173,26 @@ public class HrmConfigHolidayTests
.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.
// ============ 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_DuplicateOfSoftDeletedRow_ThrowsDbUpdate_DueToUnfilteredUniqueIndex()
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 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);
// 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).
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(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");
}
}