using System.Net; using System.Net.Http.Json; using System.Text.Json; using GerbilManagerWebAPI.Import; using GerbilManagerWebAPI.Models; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; namespace GerbilManager.Tests; /// /// FEEDBACK: the "Fehler melden" report sink. /// - POST /feedback persists a row (with captured debug context) and GET /feedback returns it. /// - Validation: an empty message is rejected with 400. /// - CRITICAL: feedback rows survive the import re-ingest wipe (loose, FK-free GerbilId/LitterId). /// public class FeedbackEndpointTests : IClassFixture { private readonly ApiFactory _factory; public FeedbackEndpointTests(ApiFactory factory) => _factory = factory; [Fact] public async Task Post_feedback_persists_row_with_debug_context() { var client = _factory.CreateClient(); var gerbilId = Guid.NewGuid(); var resp = await client.PostAsJsonAsync("/feedback", new { message = "Der Stammbaum zeigt den falschen Vater.", context = "stammbaum", gerbilId, litterId = (Guid?)null, entityName = "Krümel", url = "http://localhost:5173/rennmaeuse/kruemel/stammbaum", clientTimestamp = "2026-06-22T12:00:00Z", }); Assert.Equal(HttpStatusCode.Created, resp.StatusCode); var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement; Assert.False(string.IsNullOrEmpty(created.GetProperty("id").GetString())); Assert.Equal("stammbaum", created.GetProperty("context").GetString()); Assert.Equal("Krümel", created.GetProperty("entityName").GetString()); Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString()); // A freshly created ticket starts Open with no ResolvedAt. Assert.Equal("Open", created.GetProperty("status").GetString()); Assert.Equal(JsonValueKind.Null, created.GetProperty("resolvedAt").ValueKind); // GET returns it (newest first) var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement; Assert.Contains(listed.EnumerateArray(), f => f.GetProperty("entityName").GetString() == "Krümel" && f.GetProperty("message").GetString() == "Der Stammbaum zeigt den falschen Vater." && f.GetProperty("status").GetString() == "Open"); } [Fact] public async Task Feedback_crud_lifecycle_edit_resolve_reopen_delete() { var client = _factory.CreateClient(); // Create var create = await client.PostAsJsonAsync("/feedback", new { message = "Gewicht wird falsch gerundet.", context = "gerbil-detail", entityName = "Balu", }); Assert.Equal(HttpStatusCode.Created, create.StatusCode); var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); Assert.False(string.IsNullOrEmpty(id)); // List contains it var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement; Assert.Contains(listed.EnumerateArray(), f => f.GetProperty("id").GetString() == id); // Edit message var edit = await client.PutAsJsonAsync($"/feedback/{id}", new { message = "Gewicht wird falsch gerundet (auf der Verlaufskurve)." }); Assert.Equal(HttpStatusCode.OK, edit.StatusCode); var edited = JsonDocument.Parse(await edit.Content.ReadAsStringAsync()).RootElement; Assert.Equal("Gewicht wird falsch gerundet (auf der Verlaufskurve).", edited.GetProperty("message").GetString()); Assert.Equal("Open", edited.GetProperty("status").GetString()); // Empty message is rejected var empty = await client.PutAsJsonAsync($"/feedback/{id}", new { message = " " }); Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode); // Mark resolved -> ResolvedAt is set var resolve = await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" }); Assert.Equal(HttpStatusCode.OK, resolve.StatusCode); var resolved = JsonDocument.Parse(await resolve.Content.ReadAsStringAsync()).RootElement; Assert.Equal("Resolved", resolved.GetProperty("status").GetString()); Assert.NotEqual(JsonValueKind.Null, resolved.GetProperty("resolvedAt").ValueKind); // Reopen -> ResolvedAt cleared var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Open" }); Assert.Equal(HttpStatusCode.OK, reopen.StatusCode); var reopened = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement; Assert.Equal("Open", reopened.GetProperty("status").GetString()); Assert.Equal(JsonValueKind.Null, reopened.GetProperty("resolvedAt").ValueKind); // Soft-Delete -> 204; das Ticket bleibt erhalten (DeletedAt gesetzt) und ist über GET noch da. var del = await client.DeleteAsync($"/feedback/{id}"); Assert.Equal(HttpStatusCode.NoContent, del.StatusCode); var afterDelete = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement; var deletedRow = afterDelete.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id); Assert.NotEqual(JsonValueKind.Null, deletedRow.GetProperty("deletedAt").ValueKind); // Wiederherstellen -> DeletedAt wieder null, Ticket weiterhin bearbeitbar. var restore = await client.PostAsync($"/feedback/{id}/restore", null); Assert.Equal(HttpStatusCode.OK, restore.StatusCode); var restored = JsonDocument.Parse(await restore.Content.ReadAsStringAsync()).RootElement; Assert.Equal(JsonValueKind.Null, restored.GetProperty("deletedAt").ValueKind); // Delete/Restore eines unbekannten Tickets -> 404. var delMissing = await client.DeleteAsync($"/feedback/{Guid.NewGuid()}"); Assert.Equal(HttpStatusCode.NotFound, delMissing.StatusCode); var restoreMissing = await client.PostAsync($"/feedback/{Guid.NewGuid()}/restore", null); Assert.Equal(HttpStatusCode.NotFound, restoreMissing.StatusCode); } [Fact] public async Task Reopen_resolved_ticket_without_Rueckfrage_sets_NeedsInfo_and_ReopenedAt() { var client = _factory.CreateClient(); var create = await client.PostAsJsonAsync("/feedback", new { message = "Farbschlag stimmt nicht.", context = "gerbil-detail", }); var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); // Direkt (ohne Rückfrage) lösen … await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved", fixNote = "behoben" }); // … und wieder öffnen: Frontend schickt NeedsInfo + Bitte-um-Infos-Frage. var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new { status = "NeedsInfo", question = "Bitte beschreibe, was noch fehlt.", }); var r = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement; Assert.Equal("NeedsInfo", r.GetProperty("status").GetString()); Assert.Equal("Bitte beschreibe, was noch fehlt.", r.GetProperty("question").GetString()); // Echt gelöstes Ticket ohne vorherige Rückfrage -> ReopenedAt gesetzt. Assert.NotEqual(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind); } [Fact] public async Task Attachment_upload_list_serve_delete_lifecycle() { var client = _factory.CreateClient(); var create = await client.PostAsJsonAsync("/feedback", new { message = "Foto vom Fellschlag.", context = "gerbil-detail", }); var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); // 1×1-PNG hochladen. const string pngB64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new { fileName = "maus.png", contentType = "image/png", dataBase64 = pngB64, }); Assert.Equal(HttpStatusCode.Created, up.StatusCode); var att = JsonDocument.Parse(await up.Content.ReadAsStringAsync()).RootElement; var attId = att.GetProperty("id").GetString(); Assert.Equal("maus.png", att.GetProperty("fileName").GetString()); Assert.True(att.GetProperty("size").GetInt32() > 0); // GET /feedback liefert die Metadaten (ohne Bytes). var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement; var row = listed.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id); Assert.Equal(1, row.GetProperty("attachments").GetArrayLength()); // Bytes ausliefern. var bytes = await client.GetByteArrayAsync($"/feedback/attachments/{attId}"); Assert.Equal(Convert.FromBase64String(pngB64).Length, bytes.Length); // Löschen -> danach keine Anhänge mehr. var del = await client.DeleteAsync($"/feedback/attachments/{attId}"); Assert.Equal(HttpStatusCode.NoContent, del.StatusCode); var after = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement; var rowAfter = after.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id); Assert.Equal(0, rowAfter.GetProperty("attachments").GetArrayLength()); } [Fact] public async Task Attachment_upload_rejects_invalid_base64() { var client = _factory.CreateClient(); var create = await client.PostAsJsonAsync("/feedback", new { message = "x", context = "gerbil-detail" }); var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new { fileName = "x.png", contentType = "image/png", dataBase64 = "###nicht base64###", }); Assert.Equal(HttpStatusCode.BadRequest, up.StatusCode); } [Fact] public async Task Reopen_resolved_ticket_that_had_a_Rueckfrage_keeps_question_and_no_ReopenedAt() { var client = _factory.CreateClient(); var create = await client.PostAsJsonAsync("/feedback", new { message = "Welches Tier ist gemeint?", context = "gerbil-detail", }); var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); // Rückfrage stellen (NeedsInfo) … await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Meinst du A oder B?" }); // … manuell lösen … await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" }); // … und wieder öffnen: Frontend schickt dieselbe Frage erneut (keine Bitte-um-Infos). var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new { status = "NeedsInfo", question = "Meinst du A oder B?", }); var r = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement; Assert.Equal("NeedsInfo", r.GetProperty("status").GetString()); Assert.Equal("Meinst du A oder B?", r.GetProperty("question").GetString()); // Hatte bereits eine offene Rückfrage -> KEIN „Wieder geöffnet am"-Zeitstempel. Assert.Equal(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind); } [Fact] public async Task Put_question_sets_NeedsInfo_and_answer_sets_Answered() { var client = _factory.CreateClient(); // Create an open ticket. var create = await client.PostAsJsonAsync("/feedback", new { message = "Wurf B hat ein falsches Geburtsdatum.", context = "litter-detail", entityName = "Wurf B", }); Assert.Equal(HttpStatusCode.Created, create.StatusCode); var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); // Attach a clarifying question -> Status becomes NeedsInfo, question echoed back, no answer yet. var ask = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Welches Datum stimmt?" }); Assert.Equal(HttpStatusCode.OK, ask.StatusCode); var asked = JsonDocument.Parse(await ask.Content.ReadAsStringAsync()).RootElement; Assert.Equal("NeedsInfo", asked.GetProperty("status").GetString()); Assert.Equal("Welches Datum stimmt?", asked.GetProperty("question").GetString()); Assert.Equal(JsonValueKind.Null, asked.GetProperty("answer").ValueKind); Assert.Equal(JsonValueKind.Null, asked.GetProperty("answeredAt").ValueKind); // Breeder answers -> Status becomes Answered, AnsweredAt stamped, answer echoed, question retained. var reply = await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Der 2. Juni." }); Assert.Equal(HttpStatusCode.OK, reply.StatusCode); var answered = JsonDocument.Parse(await reply.Content.ReadAsStringAsync()).RootElement; Assert.Equal("Answered", answered.GetProperty("status").GetString()); Assert.Equal("Der 2. Juni.", answered.GetProperty("answer").GetString()); Assert.Equal("Welches Datum stimmt?", answered.GetProperty("question").GetString()); Assert.NotEqual(JsonValueKind.Null, answered.GetProperty("answeredAt").ValueKind); } [Fact] public async Task Put_question_on_resolved_ticket_keeps_it_resolved() { var client = _factory.CreateClient(); var create = await client.PostAsJsonAsync("/feedback", new { message = "x", context = "gerbil-detail" }); var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" }); var put = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Noch eine Frage?" }); var dto = JsonDocument.Parse(await put.Content.ReadAsStringAsync()).RootElement; Assert.Equal("Resolved", dto.GetProperty("status").GetString()); Assert.Equal("Noch eine Frage?", dto.GetProperty("question").GetString()); } [Fact] public async Task Put_resolve_with_fixNote_stores_breeder_changelog() { var client = _factory.CreateClient(); var create = await client.PostAsJsonAsync("/feedback", new { message = "Vater falsch.", context = "stammbaum", entityName = "Yuki" }); var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); // Resolve the ticket together with a breeder-friendly fix note (changelog). var resolve = await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved", fixNote = "Die Mutter von Yuki war falsch — sie ist jetzt Izumi. Bitte den Stammbaum neu laden.", }); Assert.Equal(HttpStatusCode.OK, resolve.StatusCode); var dto = JsonDocument.Parse(await resolve.Content.ReadAsStringAsync()).RootElement; Assert.Equal("Resolved", dto.GetProperty("status").GetString()); Assert.Equal("Die Mutter von Yuki war falsch — sie ist jetzt Izumi. Bitte den Stammbaum neu laden.", dto.GetProperty("fixNote").GetString()); Assert.NotEqual(JsonValueKind.Null, dto.GetProperty("resolvedAt").ValueKind); // The fix note survives a re-fetch via GET. var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement; Assert.Contains(listed.EnumerateArray(), f => f.GetProperty("id").GetString() == id && f.GetProperty("fixNote").GetString()!.StartsWith("Die Mutter von Yuki war falsch")); } [Fact] public async Task Put_agentContext_round_trips_without_changing_status() { var client = _factory.CreateClient(); var create = await client.PostAsJsonAsync("/feedback", new { message = "Gewicht falsch.", context = "gerbil-detail" }); var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); // Setting only the internal agent context must NOT change the lifecycle status. var put = await client.PutAsJsonAsync($"/feedback/{id}", new { agentContext = "{\"rootCause\":\"rounding in WeightChart\",\"files\":[\"WeightChart.tsx\"],\"plan\":\"fix toFixed\"}", }); Assert.Equal(HttpStatusCode.OK, put.StatusCode); var dto = JsonDocument.Parse(await put.Content.ReadAsStringAsync()).RootElement; Assert.Equal("Open", dto.GetProperty("status").GetString()); Assert.Equal("{\"rootCause\":\"rounding in WeightChart\",\"files\":[\"WeightChart.tsx\"],\"plan\":\"fix toFixed\"}", dto.GetProperty("agentContext").GetString()); // It is returned by GET as well (for tooling) but the UI never renders it. var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement; Assert.Contains(listed.EnumerateArray(), f => f.GetProperty("id").GetString() == id && f.GetProperty("agentContext").GetString()!.Contains("rootCause")); } [Fact] public async Task New_question_pushes_prior_QandA_into_thread() { var client = _factory.CreateClient(); var create = await client.PostAsJsonAsync("/feedback", new { message = "Wurf C unklar.", context = "litter-detail", entityName = "Wurf C" }); var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); // Round 1: maintainer asks, breeder answers. await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Wie heißt die Mutter?" }); await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Die Mutter ist Luna." }); // Round 2: a NEW question is asked while the prior Q&A exists. var ask2 = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Und wer ist der Vater?" }); Assert.Equal(HttpStatusCode.OK, ask2.StatusCode); var dto = JsonDocument.Parse(await ask2.Content.ReadAsStringAsync()).RootElement; // Current open exchange = the new question, fresh (no answer yet), status NeedsInfo. Assert.Equal("NeedsInfo", dto.GetProperty("status").GetString()); Assert.Equal("Und wer ist der Vater?", dto.GetProperty("question").GetString()); Assert.Equal(JsonValueKind.Null, dto.GetProperty("answer").ValueKind); Assert.Equal(JsonValueKind.Null, dto.GetProperty("answeredAt").ValueKind); // The prior round was archived into the thread (oldest first): maintainer Q then breeder A. var thread = dto.GetProperty("thread"); Assert.Equal(JsonValueKind.Array, thread.ValueKind); Assert.Equal(2, thread.GetArrayLength()); Assert.Equal("maintainer", thread[0].GetProperty("role").GetString()); Assert.Equal("Wie heißt die Mutter?", thread[0].GetProperty("text").GetString()); Assert.Equal("breeder", thread[1].GetProperty("role").GetString()); Assert.Equal("Die Mutter ist Luna.", thread[1].GetProperty("text").GetString()); // Round 2 answered, then round 3 question → thread now holds 4 entries (two full rounds). await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Der Vater ist Max." }); var ask3 = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Stimmt das Geburtsdatum?" }); var dto3 = JsonDocument.Parse(await ask3.Content.ReadAsStringAsync()).RootElement; Assert.Equal(4, dto3.GetProperty("thread").GetArrayLength()); Assert.Equal("Stimmt das Geburtsdatum?", dto3.GetProperty("question").GetString()); } [Fact] public async Task Put_and_delete_unknown_id_return_404() { var client = _factory.CreateClient(); var missing = Guid.NewGuid(); Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/feedback/{missing}", new { message = "x" })).StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/feedback/{missing}")).StatusCode); } [Fact] public async Task Post_feedback_rejects_empty_message() { var client = _factory.CreateClient(); var resp = await client.PostAsJsonAsync("/feedback", new { message = " ", context = "gerbil-detail" }); Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); } [Fact] public async Task Feedback_survives_ingest_wipe() { // Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests). var dir = Path.Combine(Path.GetTempPath(), "feedback-ingest-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); try { var contactId = Guid.NewGuid(); var fatherId = Guid.NewGuid(); var motherId = Guid.NewGuid(); var litterId = Guid.NewGuid(); var data = new { Contacts = new[] { new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null } }, Litters = new[] { new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" } }, Gerbils = new[] { Animal(fatherId, "Papa", "male", contactId), Animal(motherId, "Mama", "female", contactId), }, GerbilPhotos = Array.Empty(), }; File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data)); var opts = new DbContextOptionsBuilder() .UseInMemoryDatabase("feedback-ingest-" + Guid.NewGuid().ToString("N")) .Options; using var db = new ApplicationContext(opts); db.Database.EnsureCreated(); // A feedback report referencing the gerbil + litter that the wipe will delete. var feedbackId = Guid.NewGuid(); db.Feedback.Add(new Feedback { Id = feedbackId, Message = "Bitte prüfen.", Context = "gerbil-detail", GerbilId = fatherId, LitterId = litterId, EntityName = "Papa", Url = "http://localhost/rennmaeuse/papa", CreatedAt = DateTimeOffset.UtcNow, Status = "Answered", ResolvedAt = null, Question = "Welches Datum stimmt?", Answer = "Der 2. Juni.", AnsweredAt = DateTimeOffset.UtcNow, FixNote = "Das Geburtsdatum von Papa wurde korrigiert.", AgentContext = "{\"plan\":\"awaiting DOB confirmation\"}", Thread = "[{\"role\":\"maintainer\",\"text\":\"Erste Frage?\",\"at\":\"2026-06-20T10:00:00Z\"},{\"role\":\"breeder\",\"text\":\"Erste Antwort.\",\"at\":\"2026-06-20T11:00:00Z\"}]", }); // A contact-scoped feedback report — the ContactId is a loose (FK-free) id, // so it must survive the contact-table wipe just like gerbil/litter ids. var contactFeedbackId = Guid.NewGuid(); db.Feedback.Add(new Feedback { Id = contactFeedbackId, Message = "Adresse stimmt nicht.", Context = "contact-detail", ContactId = contactId, EntityName = "Test Breeder", Url = "http://localhost/kontakte/test-breeder", CreatedAt = DateTimeOffset.UtcNow, }); await db.SaveChangesAsync(); var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { { "Import:SourcePath", dir } }) .Build(); // Run the ingest wipe + reload. var result = await new IngestResolvedService(db, config, null!).RunAsync(); Assert.Contains("Ingestion successful!", result); // Gerbils/litters/contacts were wiped & re-created, but feedback is untouched. var survivor = await db.Feedback.SingleAsync(f => f.Id == feedbackId); Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated Assert.Equal(litterId, survivor.LitterId); Assert.Equal("Papa", survivor.EntityName); Assert.Equal("Answered", survivor.Status); // ticket status column survives the wipe // The new question/answer columns survive the wipe too. Assert.Equal("Welches Datum stimmt?", survivor.Question); Assert.Equal("Der 2. Juni.", survivor.Answer); Assert.NotNull(survivor.AnsweredAt); // The new fix-note / agent-context / thread columns survive the wipe too. Assert.Equal("Das Geburtsdatum von Papa wurde korrigiert.", survivor.FixNote); Assert.Equal("{\"plan\":\"awaiting DOB confirmation\"}", survivor.AgentContext); Assert.Contains("Erste Frage?", survivor.Thread); // The contact-scoped report also survives the contacts wipe (loose ContactId). var contactSurvivor = await db.Feedback.SingleAsync(f => f.Id == contactFeedbackId); Assert.Equal(contactId, contactSurvivor.ContactId); Assert.Equal("contact-detail", contactSurvivor.Context); Assert.Equal("Test Breeder", contactSurvivor.EntityName); Assert.Equal(2, await db.Feedback.CountAsync()); } finally { try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ } } } private static object Animal(Guid id, string name, string gender, Guid contactId) => new { Id = id, Name = name, Gender = gender, Status = "Breeding", LitterId = (Guid?)null, OriginContactId = contactId, ReceiverContactId = (Guid?)null, EnclosureId = (Guid?)null, ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), 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-" + name, RawImportData = "{}", OriginBreeder = "Test Zucht", NameSearch = name.ToLowerInvariant(), CharacterTraits = Array.Empty(), CharacterNote = (string?)null, IsDeaf = false, IsResident = true, }; }