Fix import FK fault: derived/back-linked litter referenced a not-yet-inserted parent

BUG (blocked the supervised live re-import): /import/execute threw
Npgsql 23503 FK_Litters_Gerbils_FatherId and rolled back. Root cause was
INSERT ORDERING — synthesized derived litters were SaveChanges()'d BEFORE
the parent gerbils (created later in the animal loop), so the litter's
Father/MotherId pointed at rows that didn't exist yet. The dry-run and the
EF in-memory test provider don't enforce FKs, so it slipped through.

Fix:
- Stage synthesized litters in the context but DON'T save them early; the
  single SaveChanges after the animal loop lets EF order parents → litters →
  offspring (all FKs nullable). Saving litters first was the fault.
- FK-integrity guard (god's spec): compute the persisted set (existing DB +
  this run's loadable) and null out any litter parent FK not in it; SKIP a
  derived litter whose BOTH parents are unresolvable (offspring loads with
  LitterId=null). Quarantined parents already resolve to null via
  ResolveParentGid; this is defense-in-depth + makes the invariant explicit.
- Report litterParentFksDropped + derivedLittersSkipped (LitterSummary) +
  a German note — so a green dry-run (0/0) GUARANTEES execute won't FK-fault.

Tests: two SQLite-backed regressions (SQLite enforces FKs, unlike the
in-memory provider) — a derived litter with NEW chart parents executes
without throwing, and a quarantined parent leaves that FK null. 120 C#
tests + python green; no schema change (has-pending clean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 11:32:44 +02:00
parent 36454f3747
commit fd48f7fc09
3 changed files with 135 additions and 11 deletions

View File

@@ -1,5 +1,6 @@
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace GerbilManager.Tests
@@ -191,6 +192,92 @@ namespace GerbilManager.Tests
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]
public async Task Execute_on_relational_db_with_new_chart_parents_does_not_FK_throw()
{
// Regression for FK_Litters_Gerbils_FatherId: a derived litter references parent gerbils
// created in the SAME run, so they must be inserted before the litter. The EF in-memory
// provider does NOT enforce FKs (which masked the bug), so this uses SQLite — which does.
var dir = Path.Combine(Path.GetTempPath(), "fkfix-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
using var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
try
{
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[
{"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"parentRefs":[
{"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
{"name":"Mama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
]}
]
""");
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
using var db = new ApplicationContext(opts);
await db.Database.EnsureCreatedAsync(); // schema WITH enforced FK constraints
// must not throw FK_Litters_Gerbils_FatherId (parents inserted before the litter)
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
var c = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c");
Assert.NotNull(c.LitterId);
var litter = await db.Litters.SingleAsync(l => l.Id == c.LitterId);
Assert.Equal((await db.Gerbils.SingleAsync(g => g.ExternalRef == "papa")).Id, litter.FatherId);
Assert.Equal(0, report.Litters.ParentFksDropped);
Assert.Equal(0, report.Litters.DerivedSkipped);
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]
public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw()
{
// A chart parentRef pointing to a QUARANTINED (conflict) animal must not become an FK —
// the derived litter keeps that side null; if both sides are unresolvable, no litter.
var dir = Path.Combine(Path.GetTempPath(), "fkq-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
using var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
try
{
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[
{"id":"badpa","name":"BadPapa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true},
{"id":"goodma","name":"GoodMama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"c2","name":"C2","dob":"29.04.2024","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"parentRefs":[
{"name":"BadPapa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
{"name":"GoodMama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
]}
]
""");
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
using var db = new ApplicationContext(opts);
await db.Database.EnsureCreatedAsync();
await new ImportService(db, dir, dir).RunAsync(execute: true); // must not throw
Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "badpa")); // quarantined
var c2 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c2");
Assert.NotNull(c2.LitterId); // still linked (via mother)
var litter = await db.Litters.SingleAsync(l => l.Id == c2.LitterId);
Assert.Null(litter.FatherId); // quarantined father -> null FK
Assert.Equal((await db.Gerbils.SingleAsync(g => g.ExternalRef == "goodma")).Id, litter.MotherId);
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]
public void ComposeGenotype_strips_carets_and_fills_missing_loci()
{