using GerbilManagerWebAPI.Import; using GerbilManagerWebAPI.Models; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using System.Text.Json; namespace GerbilManager.Tests { public class IngestResolvedServiceTests : IDisposable { private readonly string _dir; private readonly string _resolvedJsonPath; private Guid ContactId { get; set; } private Guid FatherId { get; set; } private Guid ContractId { get; set; } private Guid UnknownGerbilId { get; set; } public IngestResolvedServiceTests() { _dir = Path.Combine(Path.GetTempPath(), "ingest-resolved-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_dir); _resolvedJsonPath = Path.Combine(_dir, "resolved_import.json"); var contactId = Guid.NewGuid(); var fatherId = Guid.NewGuid(); var motherId = Guid.NewGuid(); var litterId = Guid.NewGuid(); var photoId = Guid.NewGuid(); ContactId = contactId; FatherId = fatherId; ContractId = Guid.NewGuid(); UnknownGerbilId = Guid.NewGuid(); var data = new { Contacts = new[] { new { Id = contactId, Name = "Test Breeder", ZuchtName = "Test Zucht", City = "Test City", Email = "test@example.com", Homepage = "", Phone = "", Address = "", Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum\"],\"mergedRecordCount\":1,\"notes\":[\"als Züchter erkannt\"]}" } }, Litters = new[] { new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "Test litter notes", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A", Provenance = (string?)"{\"sourceFiles\":[\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":1,\"fromWurfchronik\":true,\"notes\":[\"aus Wurfchronik\"]}" } }, Gerbils = new[] { new { Id = fatherId, Name = "Papa", Gender = "male", Status = "Breeding", LitterId = (Guid?)null, OriginContactId = contactId, ReceiverContactId = (Guid?)null, EnclosureId = (Guid?)null, ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz DateOfBirth = "2025-01-01", DateOfDeath = (string?)null, CauseOfDeath = (string?)null, GoHomeDate = (string?)null, Genotype = "aa CC DD EE GG PP spsp rere", Notes = "", ImportSource = "docx-export", ExternalRef = "ext-papa", RawImportData = "{}", OriginBreeder = "Test Zucht", NameSearch = "papa", CharacterTraits = new string[] {}, CharacterNote = (string?)null, IsDeaf = false, IsResident = true, Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum von Papa.xlsx\",\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":2,\"fromWurfchronik\":true,\"notes\":[\"aus 2 Datensätzen zusammengeführt\"],\"history\":[\"In \\u201eStammbaum von Papa.xlsx\\u201c gefunden.\",\"Genotyp aus \\u201eWurfchronik-Detail.docx\\u201c.\"]}" }, new { Id = motherId, Name = "Mama", Gender = "female", Status = "Breeding", LitterId = (Guid?)null, OriginContactId = contactId, ReceiverContactId = (Guid?)null, EnclosureId = (Guid?)null, ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz DateOfBirth = "2025-01-01", DateOfDeath = (string?)null, CauseOfDeath = (string?)null, GoHomeDate = (string?)null, Genotype = "aa CC DD EE GG PP spsp rere", Notes = "", ImportSource = "docx-export", ExternalRef = "ext-mama", RawImportData = "{}", OriginBreeder = "Test Zucht", NameSearch = "mama", CharacterTraits = new string[] {}, CharacterNote = (string?)null, IsDeaf = false, IsResident = true, Provenance = (string?)null } }, GerbilPhotos = new[] { new { Id = photoId, GerbilId = fatherId, FileName = "photo1.jpg", SortOrder = 0 } }, SaleContracts = new[] { new { Id = ContractId, ContactId = contactId, Price = 35.0m, HandoverDate = "2023-09-29", ContractDate = "2023-09-29", FileName = "Zucht der kleinen Chaoten _ (Papa) - Test Breeder_.docx", // One known animal + one unknown id (must be skipped defensively). Animals = new[] { fatherId, UnknownGerbilId } } } }; File.WriteAllText(_resolvedJsonPath, JsonSerializer.Serialize(data)); } public void Dispose() { try { Directory.Delete(_dir, recursive: true); } catch { } } private ApplicationContext NewDb() { var opts = new DbContextOptionsBuilder() .UseInMemoryDatabase("ingest-resolved-" + Guid.NewGuid().ToString("N")) .Options; var db = new ApplicationContext(opts); db.Database.EnsureCreated(); return db; } [Fact] public async Task IngestResolved_loads_data_and_resolves_all_relations() { using var db = NewDb(); var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { { "Import:SourcePath", _dir } }) .Build(); var service = new IngestResolvedService(db, config, null!); var result = await service.RunAsync(); Assert.Contains("Ingestion successful!", result); // Verify counts Assert.Equal(1, await db.Contacts.CountAsync()); Assert.Equal(1, await db.Litters.CountAsync()); Assert.Equal(2, await db.Gerbils.CountAsync()); Assert.Equal(1, await db.GerbilPhotos.CountAsync()); // Verify relations var litter = await db.Litters.SingleAsync(); var father = await db.Gerbils.SingleAsync(g => g.Name == "Papa"); var mother = await db.Gerbils.SingleAsync(g => g.Name == "Mama"); var photo = await db.GerbilPhotos.SingleAsync(); Assert.Equal(father.Id, litter.FatherId); Assert.Equal(mother.Id, litter.MotherId); Assert.Equal(father.Id, photo.GerbilId); Assert.Equal(father.OriginContactId, mother.OriginContactId); // Provenance round-trips verbatim through ingest (and stays null when absent). Assert.NotNull(father.Provenance); Assert.Contains("Stammbaum von Papa.xlsx", father.Provenance); Assert.Contains("mergedRecordCount", father.Provenance); // The chronological, file-attributed history survives ingest verbatim // (stored as opaque JSON on the text column — no schema for it). Assert.Contains("history", father.Provenance); Assert.Contains("Genotyp aus", father.Provenance); Assert.Null(mother.Provenance); // Contact + litter provenance also round-trips through the ingest. var contact = await db.Contacts.SingleAsync(); Assert.NotNull(contact.Provenance); Assert.Contains("als Züchter erkannt", contact.Provenance); Assert.NotNull(litter.Provenance); Assert.Contains("aus Wurfchronik", litter.Provenance); Assert.Contains("fromWurfchronik", litter.Provenance); // SaleContracts are created from the payload, and animal links to // unknown gerbils are skipped defensively (only the known father links). Assert.Equal(1, await db.SaleContracts.CountAsync()); var contract = await db.SaleContracts.Include(c => c.Animals).SingleAsync(); Assert.Equal(ContractId, contract.Id); Assert.Equal(ContactId, contract.ContactId); Assert.Equal(35.0m, contract.Price); Assert.Equal(new DateOnly(2023, 9, 29), contract.HandoverDate); var link = Assert.Single(contract.Animals); Assert.Equal(FatherId, link.GerbilId); } [Fact] public async Task IngestResolved_recreates_sale_contracts_on_every_ingest() { using var db = NewDb(); var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { { "Import:SourcePath", _dir } }) .Build(); var service = new IngestResolvedService(db, config, null!); await service.RunAsync(); Assert.Equal(1, await db.SaleContracts.CountAsync()); // Re-ingest wipes then recreates from the (deterministic) payload — // the contract survives by being part of the payload, no duplicate PK. await service.RunAsync(); Assert.Equal(1, await db.SaleContracts.CountAsync()); var contract = await db.SaleContracts.Include(c => c.Animals).SingleAsync(); Assert.Equal(ContractId, contract.Id); Assert.Single(contract.Animals); } } }