using GerbilManagerWebAPI.Import; using GerbilManagerWebAPI.Models; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; namespace GerbilManager.Tests { /// /// 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. /// 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().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()); 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()); 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()); 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()); 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()); 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 7: P0 REGRESSION — execute works under a retrying execution strategy ── [Fact] public async Task Execute_works_under_retrying_execution_strategy() { // Regression: NpgsqlRetryingExecutionStrategy (MaxRetryCount>0) calls // OnFirstExecution() at the start of ExecuteAsync, which throws // InvalidOperationException when it detects a user-initiated transaction // that was NOT opened through the strategy. This test wires the same check // (via FakeRetryingStrategy, MaxRetryCount=1) so the bug would surface in CI // without a live Npgsql instance. // // With the BUG (direct BeginTransactionAsync before strategy.ExecuteAsync): // → OnFirstExecution sees active user tx → InvalidOperationException // With the FIX (BeginTransactionAsync inside strategy.ExecuteAsync lambda): // → OnFirstExecution: no tx yet → OK var conn = new SqliteConnection("DataSource=:memory:"); conn.Open(); var opts = new DbContextOptionsBuilder() .UseSqlite(conn) .ReplaceService() .Options; var db = new ApplicationContext(opts); db.Database.EnsureCreated(); await using (conn) await using (db) { WriteLitters(Array.Empty()); WriteAnimals(new[] { new { wsCode = "3/3", litterDob = "01.01.2023", name = "Pixie", gender = "female", owner = "Retry Adopter", abgabeDate = "01.03.2023", deathDate = "", deathCause = "", farbschlag = "" } }); // Must NOT throw InvalidOperationException (user-initiated tx rejected) var report = await new ImportDocxService(db, _dir).RunAsync(execute: true); Assert.True(report.Executed); Assert.Equal(1, report.Created); Assert.Equal(1, await db.Gerbils.CountAsync()); Assert.Equal(1, await db.Contacts.CountAsync()); } } // ── 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()); 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()); } } } // ── Helpers for Test 7 ──────────────────────────────────────────────────────── /// /// Execution strategy with MaxRetryCount=1 so that EF Core's base /// OnFirstExecution() throws when it detects a user-initiated transaction /// that was not opened through CreateExecutionStrategy().ExecuteAsync(). /// ShouldRetryOn=false → no actual retry; the check alone is what we need. /// internal sealed class FakeRetryingStrategy(ExecutionStrategyDependencies deps) : ExecutionStrategy(deps, maxRetryCount: 1, maxRetryDelay: TimeSpan.Zero) { protected override bool ShouldRetryOn(Exception exception) => false; } internal sealed class FakeRetryingStrategyFactory(ExecutionStrategyDependencies deps) : IExecutionStrategyFactory { public IExecutionStrategy Create() => new FakeRetryingStrategy(deps); } }