Files
GerbilManager/GerbilManager.Tests/IngestResolvedServiceTests.cs
Gulum 2e7911074f
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m36s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m35s
CI / Docker Build & Push (push) Successful in 1m28s
CI / Deploy auf TrueNAS (Custom App) (push) Failing after 3s
feat(triage): Ticket-Fixes (Daten + Code) + prod-fähige Triage
Daten-Fixes (conflict-decisions.json, re-ingest-stabil) für ~30 Tickets:
Merges (Jamie/Hiro/Mino/Jana/Blacky/Sakura/Malou/Socke→Marty), Eltern-Korrekturen
(Jacky/Idefix/Ichika/Roni/Ethan), Kruke→Kuke (+ Todesdatum), Targa-Wurf R14 + Druna,
Stacy/Merle/Domi/Eliza; Joghurt-Phantomwurf entfernt.

Code-Fixes:
- Gaida & alle Verstorbenen: Status wird aus Todesdatum/Abgabe abgeleitet
  (Program.cs Startup-Sweep heilt Altfälle; IngestResolved re-derived nach Freeze).
- CoCo: Scheckungsart wird bei jeder Schecke angezeigt (Platzhalter wenn leer).
- M-Wurf/Gale: über-gemergte Fremdtiere via neuem litterChildren-Override entfernt.
- renameTo eltern-verknüpfungssicher (Quell-Name im Index); dateOfDeath als Override.

Prod-fähige Triage (API):
- GET /feedback/{id} + GET /feedback?status= (kein 2-MB-Dump).
- POST /import/ingest-resolved/upload (multipart) → Ingest gegen Prod ohne SSH.

Tests: 280 Backend, 149 Frontend, alle Python, betroffene Playwright grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 00:41:43 +02:00

291 lines
13 KiB
C#

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<ApplicationContext>()
.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<string, string?>
{
{ "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<string, string?>
{
{ "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);
}
[Fact]
public async Task IngestResolved_rederives_status_after_deceased_override()
{
// Ticket 37ab228a "Gaida": ein verstorbenes Tier blieb nach dem Ingest auf Status
// 'Breeding' hängen, weil der Freeze das Todesdatum aus dem Override zurückschrieb,
// ohne den Status neu abzuleiten. Der Ingest muss GerbilStatusService.Apply erneut aufrufen.
using var db = NewDb();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", _dir } })
.Build();
db.GerbilOverrides.Add(new GerbilOverride
{
Id = Guid.NewGuid(),
GerbilId = FatherId,
EntityName = "Papa",
IsVerified = false, // "geschützt" — wird trotzdem angewandt
OverrideJson = "{\"dateOfDeath\":\"2025-10-09\"}",
UpdatedAt = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
await new IngestResolvedService(db, config, null!).RunAsync();
var father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
Assert.NotNull(father.DateOfDeath); // Todesdatum aus Override angewandt
Assert.Equal(GerbilStatus.Deceased, father.Status); // ... und Status daraus abgeleitet
}
}
}