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>
277 lines
12 KiB
C#
277 lines
12 KiB
C#
using GerbilManagerWebAPI.Import;
|
|
using GerbilManagerWebAPI.Models;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace GerbilManager.Tests
|
|
{
|
|
/// <summary>
|
|
/// 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
|
|
{
|
|
private readonly string _dir;
|
|
|
|
public ImportDocxServiceTests()
|
|
{
|
|
_dir = Path.Combine(Path.GetTempPath(), "feat8d-" + Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(_dir);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_dir, recursive: true); } catch { }
|
|
}
|
|
|
|
// One open connection keeps the :memory: SQLite DB alive across calls.
|
|
private (ApplicationContext db, SqliteConnection conn) NewSqliteDb()
|
|
{
|
|
var conn = new SqliteConnection("DataSource=:memory:");
|
|
conn.Open();
|
|
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
|
var db = new ApplicationContext(opts);
|
|
db.Database.EnsureCreated(); // schema WITH unique index on ExternalRef
|
|
return (db, conn);
|
|
}
|
|
|
|
private void WriteLitters(object litters) =>
|
|
File.WriteAllText(Path.Combine(_dir, "docx_litters.json"),
|
|
System.Text.Json.JsonSerializer.Serialize(litters));
|
|
|
|
private void WriteAnimals(object animals) =>
|
|
File.WriteAllText(Path.Combine(_dir, "docx_animals.json"),
|
|
System.Text.Json.JsonSerializer.Serialize(animals));
|
|
|
|
// ── Test 1: dry-run shows correct counts without writing ─────────────────
|
|
|
|
[Fact]
|
|
public async Task DryRun_counts_new_animal_without_writing()
|
|
{
|
|
var (db, conn) = NewSqliteDb();
|
|
await using (conn)
|
|
await using (db)
|
|
{
|
|
// Litter in DB — date-only lookup (±5 days, exactly one candidate)
|
|
var litter = new Litter
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = "Testwurf",
|
|
Date = new DateOnly(2023, 5, 1),
|
|
};
|
|
db.Litters.Add(litter);
|
|
await db.SaveChangesAsync();
|
|
|
|
WriteLitters(Array.Empty<object>());
|
|
WriteAnimals(new[]
|
|
{
|
|
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
|
|
owner = "Max Mustermann", abgabeDate = "01.07.2023",
|
|
deathDate = "", deathCause = "", farbschlag = "" }
|
|
});
|
|
|
|
var report = await new ImportDocxService(db, _dir).RunAsync(execute: false);
|
|
|
|
Assert.False(report.Executed);
|
|
Assert.Equal(1, report.Created);
|
|
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
|
|
}
|
|
}
|
|
|
|
// ── Test 2: execute creates animal with all fields + FK constraints ───────
|
|
|
|
[Fact]
|
|
public async Task Execute_creates_animal_with_litter_contact_and_goHomeDate()
|
|
{
|
|
var (db, conn) = NewSqliteDb();
|
|
await using (conn)
|
|
await using (db)
|
|
{
|
|
var litter = new Litter
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = "Testwurf",
|
|
Date = new DateOnly(2023, 5, 1),
|
|
};
|
|
db.Litters.Add(litter);
|
|
await db.SaveChangesAsync();
|
|
|
|
WriteLitters(Array.Empty<object>());
|
|
WriteAnimals(new[]
|
|
{
|
|
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
|
|
owner = "Max Mustermann", abgabeDate = "01.07.2023",
|
|
deathDate = "", deathCause = "", farbschlag = "" }
|
|
});
|
|
|
|
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
|
|
|
Assert.True(report.Executed);
|
|
Assert.Equal(1, report.Created);
|
|
Assert.Equal(1, report.ContactsCreated);
|
|
|
|
var gerbil = await db.Gerbils.SingleAsync();
|
|
Assert.Equal("Pepper", gerbil.Name);
|
|
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); // 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);
|
|
Assert.Equal("docx", gerbil.ImportSource);
|
|
Assert.StartsWith("docx-", gerbil.ExternalRef);
|
|
|
|
var contact = await db.Contacts.SingleAsync();
|
|
Assert.Equal("Max Mustermann", contact.Name);
|
|
Assert.Equal(contact.Id, gerbil.ReceiverContactId);
|
|
}
|
|
}
|
|
|
|
// ── Test 3: idempotency — second run creates zero ────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Execute_is_idempotent_second_run_creates_zero()
|
|
{
|
|
var (db, conn) = NewSqliteDb();
|
|
await using (conn)
|
|
await using (db)
|
|
{
|
|
WriteLitters(Array.Empty<object>());
|
|
WriteAnimals(new[]
|
|
{
|
|
new { wsCode = "2/3", litterDob = "15.03.2023", name = "Flash", gender = "male",
|
|
owner = "", abgabeDate = "01.05.2023",
|
|
deathDate = "", deathCause = "", farbschlag = "" }
|
|
});
|
|
|
|
var first = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
|
Assert.Equal(1, first.Created);
|
|
|
|
var second = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
|
Assert.Equal(0, second.Created); // ExternalRef path, no dupe
|
|
Assert.Equal(1, await db.Gerbils.CountAsync()); // exactly one row
|
|
}
|
|
}
|
|
|
|
// ── Test 4: existing main-import animal → enrich only, no duplicate ──────
|
|
|
|
[Fact]
|
|
public async Task Execute_enriches_existing_animal_does_not_duplicate()
|
|
{
|
|
var (db, conn) = NewSqliteDb();
|
|
await using (conn)
|
|
await using (db)
|
|
{
|
|
// Animal already in DB (from main import, ExternalRef ≠ "docx-…")
|
|
var existing = new Gerbil
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = "Eddie",
|
|
DateOfBirth = new DateOnly(2022, 8, 10),
|
|
Gender = Gender.male,
|
|
ExternalRef = "main-eddie-xyz", // NOT a "docx-" ref
|
|
IsResident = false,
|
|
};
|
|
db.Gerbils.Add(existing);
|
|
await db.SaveChangesAsync();
|
|
|
|
WriteLitters(Array.Empty<object>());
|
|
WriteAnimals(new[]
|
|
{
|
|
// Same name+dob → enrich, not create
|
|
new { wsCode = "", litterDob = "10.08.2022", name = "Eddie", gender = "male",
|
|
owner = "Anna Beispiel", abgabeDate = "01.10.2022",
|
|
deathDate = "", deathCause = "", farbschlag = "" }
|
|
});
|
|
|
|
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
|
|
|
Assert.Equal(0, report.Created);
|
|
Assert.Equal(1, await db.Gerbils.CountAsync()); // still exactly one
|
|
|
|
// GoHomeDate was enriched
|
|
var updated = await db.Gerbils.SingleAsync();
|
|
Assert.Equal(new DateOnly(2022, 10, 1), updated.GoHomeDate);
|
|
}
|
|
}
|
|
|
|
// ── Test 5: Deceased status when deathDate set ───────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Execute_sets_Deceased_status_when_deathDate_provided()
|
|
{
|
|
var (db, conn) = NewSqliteDb();
|
|
await using (conn)
|
|
await using (db)
|
|
{
|
|
WriteLitters(Array.Empty<object>());
|
|
WriteAnimals(new[]
|
|
{
|
|
new { wsCode = "1/5", litterDob = "01.01.2022", name = "Ghost", gender = "male",
|
|
owner = "", abgabeDate = "",
|
|
deathDate = "15.06.2022", deathCause = "Tumor", farbschlag = "" }
|
|
});
|
|
|
|
await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
|
|
|
var gerbil = await db.Gerbils.SingleAsync();
|
|
Assert.Equal(GerbilStatus.Deceased, gerbil.Status);
|
|
Assert.Equal(new DateOnly(2022, 6, 15), gerbil.DateOfDeath);
|
|
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());
|
|
}
|
|
}
|
|
}
|
|
}
|