Tickets können jetzt eine Rückfrage (Question) tragen und die Züchterin kann
in-app antworten. Feedback um FK-freie Felder Question/Answer/AnsweredAt erweitert
(überlebt Ingest-Wipe); Status-Lebenszyklus Open → NeedsInfo (Rückfrage gestellt)
→ Answered (beantwortet) → Resolved. Migration AddFeedbackQuestionAnswer.
PUT /feedback/{id}: question → NeedsInfo, answer → Answered+AnsweredAt; DTO gibt
die Felder zurück. Tickets-Seite (/hilfe/tickets) zeigt die Rückfrage hervorgehoben
und bietet ein Antwort-Feld + „Antworten"; Status-Badges Offen/Rückfrage offen/
Beantwortet/Gelöst.
Tests: FeedbackEndpointTests (Frage→NeedsInfo, Antwort→Answered, übersteht Ingest),
e2e tickets.spec.ts. dotnet/vitest/playwright grün.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
310 lines
15 KiB
C#
310 lines
15 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);
|
|
|
|
// Delete -> 204, then 404 on subsequent edit/delete
|
|
var del = await client.DeleteAsync($"/feedback/{id}");
|
|
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
|
|
|
var delAgain = await client.DeleteAsync($"/feedback/{id}");
|
|
Assert.Equal(HttpStatusCode.NotFound, delAgain.StatusCode);
|
|
|
|
var editGone = await client.PutAsJsonAsync($"/feedback/{id}", new { message = "noch da?" });
|
|
Assert.Equal(HttpStatusCode.NotFound, editGone.StatusCode);
|
|
}
|
|
|
|
[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_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,
|
|
});
|
|
// 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 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,
|
|
};
|
|
}
|