Compare commits
5 Commits
949602630f
...
feature/ui
| Author | SHA1 | Date | |
|---|---|---|---|
| 69ffe58d6b | |||
| d7c0698c44 | |||
| db49150206 | |||
| f6a4025d71 | |||
| 8175b150f7 |
143
GerbilManager.Tests/PartialUpdateTests.cs
Normal file
143
GerbilManager.Tests/PartialUpdateTests.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>CR-2 regression: PUT /gerbils/{id} must not silently destroy fields that
|
||||
/// the caller omits (PATCH semantics). The ForSale-toggle and Charakterbogen-save
|
||||
/// send partial bodies — all other fields must be preserved unchanged.</summary>
|
||||
public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public PartialUpdateTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
private static Guid ExtractId(string json) =>
|
||||
Guid.Parse(Regex.Match(json, "\"id\":\"([^\"]+)\"").Groups[1].Value);
|
||||
|
||||
private static string? GetStr(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString() : null;
|
||||
|
||||
private static string? GetNested(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var v) ? v.ToString() : null;
|
||||
|
||||
/// <summary>Create a fully-populated gerbil and return its id.</summary>
|
||||
private async Task<Guid> CreateFullGerbil(string name = "TestTier")
|
||||
{
|
||||
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new
|
||||
{
|
||||
name,
|
||||
gender = "female",
|
||||
genotype = "aa CC DD ee GG PP spsp rere",
|
||||
originBreeder = "Zucht der Kleinen Chaoten",
|
||||
isDeaf = false,
|
||||
characterTraits = new[] { "neugierig", "zutraulich" },
|
||||
characterNote = "Liebling der Familie",
|
||||
notes = "Eine Notiz",
|
||||
externalRef = "ext-001",
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
return ExtractId(await resp.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ tests
|
||||
|
||||
[Fact]
|
||||
public async Task ForSale_toggle_does_not_wipe_other_fields()
|
||||
{
|
||||
// Arrange: create a fully-populated gerbil
|
||||
var id = await CreateFullGerbil("ForSale-Tier");
|
||||
|
||||
// Act: send only { status: 'ForSale' } — exactly what the detail page does
|
||||
var put = await _client.PutAsync($"/gerbils/{id}",
|
||||
JsonContent.Create(new { status = "ForSale" }));
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
// Assert: re-read and check all fields survive
|
||||
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||
|
||||
Assert.Equal("ForSale", GetStr(json, "status"));
|
||||
Assert.Equal("ForSale-Tier", GetStr(json, "name"));
|
||||
Assert.Equal("female", GetStr(json, "gender"));
|
||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||
Assert.Equal("ext-001", GetStr(json, "externalRef"));
|
||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CharacterSave_does_not_wipe_other_fields()
|
||||
{
|
||||
// Arrange
|
||||
var id = await CreateFullGerbil("Charakter-Tier");
|
||||
|
||||
// Act: send only { characterTraits, characterNote } — exactly what the Charakterbogen save does
|
||||
var put = await _client.PutAsync($"/gerbils/{id}", JsonContent.Create(new
|
||||
{
|
||||
characterTraits = new[] { "handzahm" },
|
||||
characterNote = "Neue Notiz",
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
// Assert: name / genotype / originBreeder / status / externalRef all intact
|
||||
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||
|
||||
Assert.Equal("Active", GetStr(json, "status"));
|
||||
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
|
||||
Assert.Equal("female", GetStr(json, "gender"));
|
||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||
Assert.Equal("ext-001", GetStr(json, "externalRef"));
|
||||
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
|
||||
Assert.Equal("Neue Notiz", GetStr(json, "characterNote"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditForm_omitting_import_fields_keeps_them()
|
||||
{
|
||||
// Arrange: gerbil with import-set fields
|
||||
var id = await CreateFullGerbil("EditForm-Tier");
|
||||
|
||||
// Act: send a typical edit-form body that omits originBreeder/isDeaf/externalRef/characterTraits/characterNote
|
||||
var put = await _client.PutAsync($"/gerbils/{id}", JsonContent.Create(new
|
||||
{
|
||||
name = "EditForm-Tier (umbenannt)",
|
||||
gender = "female",
|
||||
genotype = "aa CC DD ee GG PP spsp rere",
|
||||
notes = "Aktualisierte Notiz",
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
// Assert: name updated, import fields survive
|
||||
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||
|
||||
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
|
||||
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
|
||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("ext-001", GetStr(json, "externalRef"));
|
||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_with_minimal_body_uses_safe_defaults()
|
||||
{
|
||||
// A minimal POST (only name) must still produce a valid gerbil
|
||||
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name = "Minimal" }));
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
|
||||
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Minimal", GetStr(json, "name"));
|
||||
Assert.Equal("Active", GetStr(json, "status"));
|
||||
// characterTraits defaults to [] on create when not provided
|
||||
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
|
||||
}
|
||||
}
|
||||
@@ -59,8 +59,8 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
// Request DTOs -----------------------------------------------------------
|
||||
|
||||
public record GerbilInput(
|
||||
string Name,
|
||||
Gender Gender,
|
||||
string? Name,
|
||||
Gender? Gender,
|
||||
GerbilStatus? Status,
|
||||
Guid? LitterId,
|
||||
Guid? OriginContactId,
|
||||
|
||||
@@ -39,19 +39,18 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
if (string.IsNullOrWhiteSpace(input.Name))
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
||||
|
||||
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name };
|
||||
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name! };
|
||||
Apply(g, input, isCreate: true);
|
||||
db.Gerbils.Add(g);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/gerbils/{g.Id}", ToDto(g));
|
||||
});
|
||||
|
||||
// PUT /gerbils/{id}
|
||||
// PUT /gerbils/{id} — PATCH semantics: omitted/null fields keep the stored value.
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, GerbilInput input, ApplicationContext db) =>
|
||||
{
|
||||
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (g is null) return TypedResults.NotFound();
|
||||
g.Name = input.Name;
|
||||
Apply(g, input, isCreate: false);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
@@ -77,27 +76,34 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
// CR-2 FIX: PATCH semantics — every omitted/null field keeps the stored value.
|
||||
// Prevents silent data loss when the frontend sends partial bodies (ForSale toggle,
|
||||
// Charakterbogen save, any partial updateGerbil call). On create, supply safe defaults
|
||||
// for fields the frontend omits. A non-null input value always wins (including explicit
|
||||
// nulls — callers that want to clear a nullable field must send a full object; a
|
||||
// dedicated PATCH endpoint can be added later if point-clear is needed).
|
||||
private static void Apply(Gerbil g, GerbilInput i, bool isCreate)
|
||||
{
|
||||
g.Gender = i.Gender;
|
||||
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
|
||||
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
|
||||
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
|
||||
g.LitterId = i.LitterId;
|
||||
g.OriginContactId = i.OriginContactId;
|
||||
g.ReceiverContactId = i.ReceiverContactId;
|
||||
g.EnclosureId = i.EnclosureId;
|
||||
g.ColorVarietyId = i.ColorVarietyId;
|
||||
g.DateOfBirth = i.DateOfBirth;
|
||||
g.DateOfDeath = i.DateOfDeath;
|
||||
g.CauseOfDeath = i.CauseOfDeath;
|
||||
g.GoHomeDate = i.GoHomeDate;
|
||||
g.Genotype = i.Genotype;
|
||||
g.Notes = i.Notes;
|
||||
g.ImportSource = i.ImportSource;
|
||||
g.ExternalRef = i.ExternalRef;
|
||||
g.OriginBreeder = i.OriginBreeder;
|
||||
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
|
||||
g.CharacterNote = i.CharacterNote;
|
||||
g.IsDeaf = i.IsDeaf;
|
||||
g.LitterId = i.LitterId ?? g.LitterId;
|
||||
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
|
||||
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
|
||||
g.EnclosureId = i.EnclosureId ?? g.EnclosureId;
|
||||
g.ColorVarietyId = i.ColorVarietyId ?? g.ColorVarietyId;
|
||||
g.DateOfBirth = i.DateOfBirth ?? g.DateOfBirth;
|
||||
g.DateOfDeath = i.DateOfDeath ?? g.DateOfDeath;
|
||||
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
|
||||
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
|
||||
g.Genotype = i.Genotype ?? g.Genotype;
|
||||
g.Notes = i.Notes ?? g.Notes;
|
||||
g.ImportSource = i.ImportSource ?? g.ImportSource;
|
||||
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
|
||||
g.OriginBreeder = i.OriginBreeder ?? g.OriginBreeder;
|
||||
g.CharacterTraits = i.CharacterTraits ?? g.CharacterTraits;
|
||||
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
|
||||
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
|
||||
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,3 +53,12 @@ test('unbekanntes Tier zeigt den deutschen Nicht-gefunden-Zustand', async ({ pag
|
||||
await page.goto('/rennmaeuse/gibt-es-nicht/stammbaum')
|
||||
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
|
||||
})
|
||||
|
||||
test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/nameless-stub/stammbaum')
|
||||
// Titel zeigt '(ohne Namen)'
|
||||
await expect(page.getByRole('heading', { name: t.titleFor(de.pages.gerbils.nameless) })).toBeVisible()
|
||||
// Karte selbst zeigt '(ohne Namen)' statt leer
|
||||
await expect(page.locator('.pedigree-card__nametext')).toHaveText(de.pages.gerbils.nameless)
|
||||
})
|
||||
|
||||
@@ -276,7 +276,7 @@ export default function StammbaumPage() {
|
||||
<>
|
||||
<section className="page stammbaum-screen">
|
||||
<header className="page-head">
|
||||
<h2>{t.titleFor(root.gerbil.name)}</h2>
|
||||
<h2>{t.titleFor(root.gerbil.name || de.pages.gerbils.nameless)}</h2>
|
||||
<div className="head-actions">
|
||||
<Link to={`/rennmaeuse/${root.gerbil.id}`} className="btn">
|
||||
{t.backToAnimal}
|
||||
@@ -377,7 +377,7 @@ function PedigreeCard({
|
||||
<div className="pedigree-card__body">
|
||||
<div className="pedigree-card__name">
|
||||
<SexIcon gender={g.gender} />
|
||||
<span className="pedigree-card__nametext">{g.name}</span>
|
||||
<span className="pedigree-card__nametext">{g.name || de.pages.gerbils.nameless}</span>
|
||||
</div>
|
||||
{farbschlag && (
|
||||
<span
|
||||
@@ -439,7 +439,7 @@ function PrintPedigree({
|
||||
return (
|
||||
<div className="stammbaum-print">
|
||||
<header className="stammbaum-print__head">
|
||||
<h1>{t.titleFor(root.gerbil.name)}</h1>
|
||||
<h1>{t.titleFor(root.gerbil.name || de.pages.gerbils.nameless)}</h1>
|
||||
<p className="stammbaum-print__meta">
|
||||
{t.inbreeding.label}: {inbreedingText} · {t.printView.createdOn} {today}
|
||||
</p>
|
||||
@@ -494,7 +494,7 @@ function PrintCell({
|
||||
<div className={base} style={style}>
|
||||
<div className="stammbaum-print__name">
|
||||
{sexSymbol}
|
||||
{g.name}
|
||||
{g.name || de.pages.gerbils.nameless}
|
||||
</div>
|
||||
{g.dateOfBirth && (
|
||||
<div className="stammbaum-print__sub">
|
||||
|
||||
@@ -326,7 +326,7 @@ export default function VertragWizardPage() {
|
||||
checked={selectedIds.has(g.id)}
|
||||
onChange={() => toggleAnimal(g.id)}
|
||||
/>
|
||||
<span className="wizard-pick__name">{g.name}</span>
|
||||
<span className="wizard-pick__name">{g.name || de.pages.gerbils.nameless}</span>
|
||||
<span className="wizard-pick__meta">
|
||||
{[
|
||||
genderLabel(g.gender),
|
||||
@@ -396,7 +396,7 @@ export default function VertragWizardPage() {
|
||||
</div>
|
||||
<div className="def-row">
|
||||
<dt>{de.pages.vertraege.fields.animals}</dt>
|
||||
<dd>{selectedAnimals.map((g) => g.name).join(', ')}</dd>
|
||||
<dd>{selectedAnimals.map((g) => g.name || de.pages.gerbils.nameless).join(', ')}</dd>
|
||||
</div>
|
||||
<div className="def-row">
|
||||
<dt>{de.pages.vertraege.fields.price}</dt>
|
||||
|
||||
@@ -71,9 +71,9 @@ export default function WurfFormPage() {
|
||||
name: l.name,
|
||||
date: l.date ?? '',
|
||||
fatherId: l.fatherId ?? '',
|
||||
fatherName: l.fatherId ? (nameById.get(l.fatherId) ?? '') : '',
|
||||
fatherName: l.fatherId ? (nameById.get(l.fatherId) || de.pages.gerbils.nameless) : '',
|
||||
motherId: l.motherId ?? '',
|
||||
motherName: l.motherId ? (nameById.get(l.motherId) ?? '') : '',
|
||||
motherName: l.motherId ? (nameById.get(l.motherId) || de.pages.gerbils.nameless) : '',
|
||||
totalBorn: l.totalBorn != null ? String(l.totalBorn) : '',
|
||||
expectedGoHomeDate: l.expectedGoHomeDate ?? '',
|
||||
notes: l.notes ?? '',
|
||||
@@ -178,7 +178,7 @@ export default function WurfFormPage() {
|
||||
) : (
|
||||
<AnimalPicker
|
||||
gender="male"
|
||||
onPick={(g) => setForm((f) => ({ ...f, fatherId: g.id, fatherName: g.name }))}
|
||||
onPick={(g) => setForm((f) => ({ ...f, fatherId: g.id, fatherName: g.name || de.pages.gerbils.nameless }))}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
@@ -199,7 +199,7 @@ export default function WurfFormPage() {
|
||||
) : (
|
||||
<AnimalPicker
|
||||
gender="female"
|
||||
onPick={(g) => setForm((f) => ({ ...f, motherId: g.id, motherName: g.name }))}
|
||||
onPick={(g) => setForm((f) => ({ ...f, motherId: g.id, motherName: g.name || de.pages.gerbils.nameless }))}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
Reference in New Issue
Block a user