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 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