using System.Text.Json; using System.Text.Json.Nodes; using GerbilManagerWebAPI.Import; using GerbilManagerWebAPI.Models; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; namespace GerbilManager.Tests { /// /// Regression tests for the "Freeze/Override" mechanism (verified key animals + manual work /// survive the re-ingest). Exercised at the service + ingest level with an InMemory DB, mirroring /// the harness in . /// public class VerifiedGerbilTests : IDisposable { private static readonly Guid Schwarz = new("00000000-0000-0000-0000-000000000006"); private readonly List _dirs = new(); public void Dispose() { foreach (var d in _dirs) try { Directory.Delete(d, recursive: true); } catch { } } // ── Harness helpers ────────────────────────────────────────────────── private string NewDir() { var dir = Path.Combine(Path.GetTempPath(), "verified-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); _dirs.Add(dir); return dir; } private static ApplicationContext NewDb() { var opts = new DbContextOptionsBuilder() .UseInMemoryDatabase("verified-" + Guid.NewGuid().ToString("N")) .Options; var db = new ApplicationContext(opts); db.Database.EnsureCreated(); return db; } private static IngestResolvedService Service(ApplicationContext db, string dir) { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { { "Import:SourcePath", dir }, { "Photos:RootPath", dir }, }) .Build(); return new IngestResolvedService(db, config, null!); } private static void WritePayload(string dir, object[] gerbils, object[]? litters = null, object[]? contacts = null) => File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(new { Contacts = contacts ?? Array.Empty(), Litters = litters ?? Array.Empty(), Gerbils = gerbils, GerbilPhotos = Array.Empty(), SaleContracts = Array.Empty(), })); private static object ImportGerbil(Guid id, string name, string genotype, string gender = "female", string dob = "2022-01-01") => new { Id = id, Name = name, Gender = gender, Status = "Breeding", DateOfBirth = dob, Genotype = genotype, ColorVarietyId = Schwarz, IsResident = true, OriginBreeder = "Test Zucht", }; // ── 1. Manual animal survives the ingest (never wiped) ─────────────── [Fact] public async Task Manual_animal_survives_ingest_that_omits_it() { using var db = NewDb(); var dir = NewDir(); var manualId = Guid.NewGuid(); db.Gerbils.Add(new Gerbil { Id = manualId, Name = "Handmaus", Gender = Gender.female, IsManual = true }); await db.SaveChangesAsync(); var importedId = Guid.NewGuid(); WritePayload(dir, new[] { ImportGerbil(importedId, "Importiert", "aa CC DD EE GG PP spsp") }); var result = await Service(db, dir).RunAsync(); Assert.StartsWith("Ingestion successful!", result); // Manual row is still there, unchanged. var manual = await db.Gerbils.FindAsync(manualId); Assert.NotNull(manual); Assert.True(manual!.IsManual); Assert.Equal("Handmaus", manual.Name); // Imported row was loaded. Assert.NotNull(await db.Gerbils.FindAsync(importedId)); Assert.Equal(2, await db.Gerbils.CountAsync()); } // ── 2. Verified freeze forces the golden values over a drifted import ─ [Fact] public async Task Verified_override_forces_golden_and_records_drift() { using var db = NewDb(); var dir = NewDir(); var id = new Guid("11111111-1111-1111-1111-111111111111"); // First ingest: import brings X in with the WRONG genotype/name. WritePayload(dir, new[] { ImportGerbil(id, "Falschname", "aa dd DD EE GG PP spsp") }); var svc = Service(db, dir); await svc.RunAsync(); // The breeder corrects X and marks it "vollständig korrekt". var g = await db.Gerbils.FindAsync(id); Assert.NotNull(g); g!.Name = "Korrekt"; g.Genotype = "aa CC DD EE GG PP spsp"; await db.SaveChangesAsync(); var golden = await GerbilSnapshotService.BuildSnapshotAsync(db, id); db.GerbilOverrides.Add(new GerbilOverride { Id = Guid.NewGuid(), GerbilId = id, EntityName = g.Name, IsVerified = true, OverrideJson = GerbilSnapshotService.BuildFreezeJson(g), SnapshotJson = GerbilSnapshotService.SerializeSnapshot(golden!), VerifiedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow, }); await db.SaveChangesAsync(); // A later import DRIFTS back to wrong values. WritePayload(dir, new[] { ImportGerbil(id, "Wiederfalsch", "aa dd DD EE GG PP spsp") }); await svc.RunAsync(); // Golden wins (freeze re-applied on top of the fresh import). var after = await db.Gerbils.FindAsync(id); Assert.Equal("Korrekt", after!.Name); Assert.Equal("aa CC DD EE GG PP spsp", after.Genotype); // Drift was detected and recorded, naming the offending fields. var ov = await db.GerbilOverrides.SingleAsync(); Assert.False(string.IsNullOrEmpty(ov.LastImportDiffJson)); Assert.Contains("genotype", ov.LastImportDiffJson); Assert.Contains("name", ov.LastImportDiffJson); Assert.NotNull(ov.LastImportSnapshotJson); } // ── 3. No drift → LastImportDiffJson stays null ────────────────────── [Fact] public async Task Verified_override_without_drift_leaves_diff_null() { using var db = NewDb(); var dir = NewDir(); var id = new Guid("22222222-2222-2222-2222-222222222222"); WritePayload(dir, new[] { ImportGerbil(id, "Stabil", "aa CC DD EE GG PP spsp") }); var svc = Service(db, dir); await svc.RunAsync(); var g = await db.Gerbils.FindAsync(id); var golden = await GerbilSnapshotService.BuildSnapshotAsync(db, id); db.GerbilOverrides.Add(new GerbilOverride { Id = Guid.NewGuid(), GerbilId = id, EntityName = g!.Name, IsVerified = true, OverrideJson = GerbilSnapshotService.BuildFreezeJson(g), SnapshotJson = GerbilSnapshotService.SerializeSnapshot(golden!), VerifiedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow, }); await db.SaveChangesAsync(); // Re-ingest with the IDENTICAL payload (== golden). await svc.RunAsync(); var ov = await db.GerbilOverrides.SingleAsync(); Assert.Null(ov.LastImportDiffJson); } // ── 4. Removing the override → import-driven again ─────────────────── [Fact] public async Task Removing_override_restores_import_driven_values() { using var db = NewDb(); var dir = NewDir(); var id = new Guid("33333333-3333-3333-3333-333333333333"); WritePayload(dir, new[] { ImportGerbil(id, "Import-Wert", "aa CC DD EE GG PP spsp") }); var svc = Service(db, dir); await svc.RunAsync(); var g = await db.Gerbils.FindAsync(id); g!.Genotype = "aa cc DD EE GG PP spsp"; // "golden" await db.SaveChangesAsync(); var golden = await GerbilSnapshotService.BuildSnapshotAsync(db, id); var ov = new GerbilOverride { Id = Guid.NewGuid(), GerbilId = id, IsVerified = true, OverrideJson = GerbilSnapshotService.BuildFreezeJson(g), SnapshotJson = GerbilSnapshotService.SerializeSnapshot(golden!), UpdatedAt = DateTimeOffset.UtcNow, }; db.GerbilOverrides.Add(ov); await db.SaveChangesAsync(); // Freeze wins while the override exists. await svc.RunAsync(); Assert.Equal("aa cc DD EE GG PP spsp", (await db.Gerbils.FindAsync(id))!.Genotype); // Remove the override → the animal follows the import again. db.GerbilOverrides.Remove(ov); await db.SaveChangesAsync(); await svc.RunAsync(); Assert.Equal("aa CC DD EE GG PP spsp", (await db.Gerbils.FindAsync(id))!.Genotype); } // ── 5. Per-field protection (pure service level) ───────────────────── [Fact] public void PerField_change_pins_only_the_changed_field() { var g = new Gerbil { Name = "Quelle", Notes = "alte Notiz", Genotype = "aa CC DD EE GG PP spsp" }; var before = GerbilSnapshotService.BuildFreezeObject(g); g.Notes = "neue Notiz"; var after = GerbilSnapshotService.BuildFreezeObject(g); var changed = GerbilSnapshotService.ChangedFields(before, after); var only = Assert.Single(changed); // ONLY notes changed Assert.Equal("notes", only.Key); var merged = GerbilSnapshotService.MergeOverrideJson("{}", changed); var mergedObj = JsonNode.Parse(merged)!.AsObject(); Assert.True(mergedObj.ContainsKey("notes")); Assert.False(mergedObj.ContainsKey("genotype")); // untouched fields not pinned // Applying it onto a different animal overwrites ONLY notes, not the genotype. var target = new Gerbil { Name = "Ziel", Notes = "Ziel-Notiz", Genotype = "zz ZZ dd ee gg pp spsp" }; GerbilSnapshotService.ApplyOverride(target, merged); Assert.Equal("neue Notiz", target.Notes); Assert.Equal("zz ZZ dd ee gg pp spsp", target.Genotype); } // ── 6. Dangling reference from a manual row is nulled + warned ──────── [Fact] public async Task Stale_parent_reference_from_manual_litter_is_nulled_with_warning() { using var db = NewDb(); var dir = NewDir(); var fatherId = new Guid("44444444-4444-4444-4444-444444444444"); // Ingest 1 brings in the (imported) father. WritePayload(dir, new[] { ImportGerbil(fatherId, "Vater", "aa CC DD EE GG PP spsp", gender: "male") }); var svc = Service(db, dir); await svc.RunAsync(); // The breeder wires a MANUAL litter to that imported father. var litterId = Guid.NewGuid(); db.Litters.Add(new Litter { Id = litterId, Name = "Manueller Wurf", FatherId = fatherId, IsManual = true }); await db.SaveChangesAsync(); // Ingest 2 no longer contains the father → it becomes stale and is removed. WritePayload(dir, Array.Empty()); var result = await svc.RunAsync(); // The manual litter survives, but its dangling parent link was nulled... var litter = await db.Litters.FindAsync(litterId); Assert.NotNull(litter); Assert.True(litter!.IsManual); Assert.Null(litter.FatherId); // ...and a warning was surfaced. Assert.Contains("warning", result); Assert.Contains("nicht mehr existiert", result); // The stale imported father is gone. Assert.Null(await db.Gerbils.FindAsync(fatherId)); } // ── 7. Diff detects ancestry / offspring differences ───────────────── [Fact] public void Diff_detects_parent_and_children_differences() { var self = new SnapshotSelf( "Proband", "female", "2022-01-01", null, null, null, "aa CC DD EE GG PP spsp", null, "Schwarz", true, null, false, null, null, null, null, new List(), null, null); var golden = new GerbilSnapshot( self, new SnapshotParent("Papa Alt", "2020-01-01", "aa CC"), null, new List { new("Wurf1", "2023-01-01", "mother", 3, true, new List { new("Kind1", "2023-01-01", "female"), }), }); var other = new GerbilSnapshot( self, new SnapshotParent("Papa Neu", "2020-01-01", "aa CC"), // different father null, new List { new("Wurf1", "2023-01-01", "mother", 3, true, new List { new("Kind1", "2023-01-01", "female"), new("Kind2", "2023-01-02", "male"), // extra child }), }); var diffs = GerbilSnapshotService.Diff(golden, other); Assert.NotEmpty(diffs); Assert.Contains(diffs, d => d.Path == "father"); Assert.Contains(diffs, d => d.Path.StartsWith("wurf[")); } } }