feat(tickets): Rückfragen-Dialog, Verlinkungen, Entwurfssicherung + PWA-Vorbereitung
- Feedback: fixNote (Changelog), agentContext (intern), thread (Q&A-Verlauf); 3 gefilterte Ansichten (Rückfragen/Offen/Geschlossen) - Tickets-Text: klickbare Tier-Links (ID→Name), Ticket→Ticket-Links (?focus=), Markdown-Links; Antwort-Entwurf pro Ticket in localStorage - "Fehler melden": Entwurf übersteht App-Wechsel/Schließen (ein gemeinsamer Entwurf) - entityName als Hyperlink zum referenzierten Datensatz Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -159,6 +159,97 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
||||
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()
|
||||
{
|
||||
@@ -231,6 +322,9 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
||||
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.
|
||||
@@ -265,6 +359,10 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user