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>
563 lines
29 KiB
C#
563 lines
29 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 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,
|
||
};
|
||
}
|