Files
GerbilManager/GerbilManager.Tests/FeedbackEndpointTests.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

563 lines
29 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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<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 */ }
}
}
[Fact]
public async Task Get_single_feedback_by_id_and_status_filter()
{
var client = _factory.CreateClient();
// Zwei Tickets mit unterscheidbaren Namen anlegen.
var open = await client.PostAsJsonAsync("/feedback", new { message = "Offenes Ticket", context = "gerbil-detail", entityName = "FilterOpen" });
var openId = JsonDocument.Parse(await open.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString()!;
var other = await client.PostAsJsonAsync("/feedback", new { message = "Zweites Ticket", context = "gerbil-detail", entityName = "FilterOther" });
var otherId = JsonDocument.Parse(await other.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString()!;
// Einzel-GET liefert genau dieses Ticket (mit attachments-Feld).
var single = JsonDocument.Parse(await client.GetStringAsync($"/feedback/{openId}")).RootElement;
Assert.Equal("FilterOpen", single.GetProperty("entityName").GetString());
Assert.Equal(JsonValueKind.Array, single.GetProperty("attachments").ValueKind);
// Unbekannte Id -> 404.
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/feedback/{Guid.NewGuid()}")).StatusCode);
// Zweites Ticket auf Answered setzen, dann Status-Filter prüfen.
await client.PutAsJsonAsync($"/feedback/{otherId}", new { status = "Answered" });
var answered = JsonDocument.Parse(await client.GetStringAsync("/feedback?status=Answered")).RootElement;
Assert.Contains(answered.EnumerateArray(), f => f.GetProperty("id").GetString() == otherId);
Assert.DoesNotContain(answered.EnumerateArray(), f => f.GetProperty("id").GetString() == openId);
// Kombinierter Filter (Open,Answered) enthält beide.
var both = JsonDocument.Parse(await client.GetStringAsync("/feedback?status=Open,Answered")).RootElement;
var ids = both.EnumerateArray().Select(f => f.GetProperty("id").GetString()).ToHashSet();
Assert.Contains(openId, ids);
Assert.Contains(otherId, ids);
}
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,
};
}