Files
GerbilManager/GerbilManager.Tests/FeedbackEndpointTests.cs
Gulum c0a7b1a5ee feat(tickets): Wiederöffnen→Rückfrage mit Zeitstempel + Soft-Delete-Papierkorb + Scroll-Erhalt
Wiederöffnen eines gelösten Tickets:
- landet jetzt in „Rückfragen" (NeedsInfo) statt „Offen".
- War es ein echt gelöstes Ticket OHNE Rückfrage, wird um mehr Infos gebeten und
  „Wieder geöffnet am" (ReopenedAt) gestempelt.
- Hatte es bereits eine offene Rückfrage (z. B. manuell gelöste Rückfrage), kehrt es
  ohne neuen Hinweis und ohne Zeitstempel in genau diese Frage zurück.

Löschen über die UI = Soft-Delete:
- Tickets wandern in die neue Kategorie „Gelöscht" (Papierkorb) statt entfernt zu werden.
- Von dort wiederherstellbar (POST /feedback/{id}/restore → DeletedAt = null).
- Countdown „Wird in N Tagen endgültig gelöscht"; nach 30 Tagen endgültige Löschung
  (lazy beim Abruf in GET /feedback).

Außerdem: Scrollposition bleibt beim Zurück-Navigieren (z. B. nach Klick auf einen
Tier-Link) erhalten, statt an den Seitenanfang zu springen.

EF-Migration FeedbackReopenedAndSoftDelete (ReopenedAt, DeletedAt — beide nullable).
Tests: 260 Backend grün (+2 Reopen-Fälle, CRUD auf Soft-Delete umgestellt), e2e Tickets
Desktop+Phone grün (+Soft-Delete/Restore/Reopen), vitest 149.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:02:04 +02:00

473 lines
24 KiB
C#

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;
/// <summary>
/// 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).
/// </summary>
public class FeedbackEndpointTests : IClassFixture<ApiFactory>
{
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 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<object>(),
};
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.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<string, string?> { { "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<string>(),
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true,
};
}