FIX-8D-DEDUP: P0 duplicate ExternalRef + WsCode→litter mismatch

FIX-1 DEDUP (P0): Two same-name siblings in one litter produced identical
ExternalRefs ('docx-{ws}-{name}-{dob}'), crashing execute on IX_Gerbils_ExternalRef.
Fix: ordinal counter per base-ref within the batch → first occurrence keeps the
base ref, subsequent ones get -2, -3 … suffix. Deterministic (JSON-order) → idempotent
re-runs find existing rows via ExternalRef path (PATH 1). HashSet guard added as a
belt-and-suspenders check.

FIX-2 TRANSACTION: execute now opens a single BeginTransactionAsync before the loop
and commits after the final SaveChangesAsync. Eager contact saves (within the tx) and
the gerbil batch save are fully atomic — crash → full rollback, no partial state.

FIX-3 LITTER LOOKUP: docx WsCode is a litter-size fraction ('4/4', '/5') — NOT a
PairingCode like 'G01/ZdkC'. The previous WsCode→PairingCode lookup was always a
no-op (hence litterLinked=0 in every dry-run). Fix: look up DB litters by LitterDob
(±5 days); link only when exactly ONE candidate exists (unambiguous, no false links).

P0 REGRESSION TEST (Test 6): SQLite + EnsureCreated → unique index enforced.
Two animals same name+litter → execute succeeds, both created, distinct ExternalRefs,
re-run = 0 new. This test would have caught the live crash.
Gate: 164/164 tests, has-pending=No, no schema change.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 23:31:46 +02:00
parent e367657254
commit d5e8946d7d
2 changed files with 239 additions and 147 deletions

View File

@@ -6,8 +6,9 @@ using Microsoft.EntityFrameworkCore;
namespace GerbilManager.Tests
{
/// <summary>
/// FEAT-8d-CREATE: docx importer CREATE path tests.
/// Uses SQLite (not InMemory) so FK constraints are enforced.
/// FEAT-8d-CREATE / FIX-8D-DEDUP: docx importer CREATE path tests.
/// Uses SQLite (not InMemory) so FK + unique-index constraints are enforced
/// this is the only reliable way to catch duplicate-ExternalRef crashes.
/// </summary>
public class ImportDocxServiceTests : IDisposable
{
@@ -31,7 +32,7 @@ namespace GerbilManager.Tests
conn.Open();
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
db.Database.EnsureCreated(); // schema WITH unique index on ExternalRef
return (db, conn);
}
@@ -52,13 +53,12 @@ namespace GerbilManager.Tests
await using (conn)
await using (db)
{
// Litter in DB with PairingCode matching the docx WS-code
// Litter in DB — date-only lookup (±5 days, exactly one candidate)
var litter = new Litter
{
Id = Guid.NewGuid(),
Name = "Testwurf WS1",
Name = "Testwurf",
Date = new DateOnly(2023, 5, 1),
PairingCode = "WS1"
};
db.Litters.Add(litter);
await db.SaveChangesAsync();
@@ -66,7 +66,7 @@ namespace GerbilManager.Tests
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "WS1", litterDob = "01.05.2023", name = "Pepper", gender = "female",
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
owner = "Max Mustermann", abgabeDate = "01.07.2023",
deathDate = "", deathCause = "", farbschlag = "" }
});
@@ -75,8 +75,8 @@ namespace GerbilManager.Tests
Assert.False(report.Executed);
Assert.Equal(1, report.Created);
Assert.Equal(1, report.LitterLinked);
Assert.Equal(1, report.GoHomeFilled); // new animal will have GoHomeDate set
Assert.Equal(1, report.LitterLinked); // date-only match finds the one litter
Assert.Equal(1, report.GoHomeFilled);
Assert.Equal(0, await db.Gerbils.CountAsync()); // nothing written
Assert.Equal(0, await db.Contacts.CountAsync()); // nothing written
}
@@ -94,9 +94,8 @@ namespace GerbilManager.Tests
var litter = new Litter
{
Id = Guid.NewGuid(),
Name = "Testwurf WS1",
Name = "Testwurf",
Date = new DateOnly(2023, 5, 1),
PairingCode = "WS1"
};
db.Litters.Add(litter);
await db.SaveChangesAsync();
@@ -104,7 +103,7 @@ namespace GerbilManager.Tests
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "WS1", litterDob = "01.05.2023", name = "Pepper", gender = "female",
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
owner = "Max Mustermann", abgabeDate = "01.07.2023",
deathDate = "", deathCause = "", farbschlag = "" }
});
@@ -120,7 +119,7 @@ namespace GerbilManager.Tests
Assert.Equal(new DateOnly(2023, 5, 1), gerbil.DateOfBirth);
Assert.Equal(Gender.female, gerbil.Gender);
Assert.Equal(GerbilStatus.GivenAway, gerbil.Status);
Assert.Equal(litter.Id, gerbil.LitterId);
Assert.Equal(litter.Id, gerbil.LitterId); // date-only link worked
Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate);
Assert.Equal("Zucht der Kleinen Chaoten", gerbil.OriginBreeder);
Assert.False(gerbil.IsResident);
@@ -145,7 +144,7 @@ namespace GerbilManager.Tests
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "WS2", litterDob = "15.03.2023", name = "Flash", gender = "male",
new { wsCode = "2/3", litterDob = "15.03.2023", name = "Flash", gender = "male",
owner = "", abgabeDate = "01.05.2023",
deathDate = "", deathCause = "", farbschlag = "" }
});
@@ -213,7 +212,7 @@ namespace GerbilManager.Tests
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "WS3", litterDob = "01.01.2022", name = "Ghost", gender = "male",
new { wsCode = "1/5", litterDob = "01.01.2022", name = "Ghost", gender = "male",
owner = "", abgabeDate = "",
deathDate = "15.06.2022", deathCause = "Tumor", farbschlag = "" }
});
@@ -226,5 +225,52 @@ namespace GerbilManager.Tests
Assert.Equal("Tumor", gerbil.CauseOfDeath);
}
}
// ── Test 6: P0 REGRESSION — same-name siblings get distinct ExternalRefs ─
[Fact]
public async Task Execute_same_name_siblings_created_with_distinct_ExternalRefs()
{
// P0 regression: two animals in the same litter with the same normalized name
// previously caused duplicate ExternalRef → Npgsql/SQLite 23505 unique-key crash.
// Fix: ordinal disambiguation (-2) ensures uniqueness within the batch.
// The unique index on IX_Gerbils_ExternalRef (via EnsureCreated on SQLite) makes
// this test an authoritative regression gate.
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female",
owner = "Eva Müller", abgabeDate = "10.08.2022",
deathDate = "", deathCause = "", farbschlag = "" },
// Identical name+wsCode+litterDob → base ExternalRef collision
new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female",
owner = "Lena Braun", abgabeDate = "11.08.2022",
deathDate = "", deathCause = "", farbschlag = "" },
});
// Must NOT throw unique-key violation
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.Equal(2, report.Created);
Assert.Equal(2, await db.Gerbils.CountAsync());
var refs = (await db.Gerbils.Select(g => g.ExternalRef!).ToListAsync()).OrderBy(r => r).ToList();
// Both start with the docx- prefix
Assert.All(refs, r => Assert.StartsWith("docx-", r));
// Must be distinct (unique index enforces this in SQLite)
Assert.Equal(2, refs.Distinct().Count());
// Second occurrence gets the -2 suffix
Assert.Single(refs, r => r.EndsWith("-2"));
// Idempotent re-run: zero new, still 2 in DB
var second = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.Equal(0, second.Created);
Assert.Equal(2, await db.Gerbils.CountAsync());
}
}
}
}