From 8175b150f772bb483950d9dbffed9496c6aab06e Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 17:40:15 +0200 Subject: [PATCH] =?UTF-8?q?CR-2=20FIX:=20PUT=20/gerbils=20PATCH-Semantik?= =?UTF-8?q?=20=E2=80=94=20partielle=20Updates=20zerstoeren=20keine=20Felde?= =?UTF-8?q?r=20mehr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GerbilInput.Name + Gender auf nullable geaendert. Apply() wendet fuer alle Felder ?? g.X an (omittierte/null Felder behalten den DB-Wert). g.Name aus dem PUT-Handler in Apply() verschoben. Verhindert stillen Datenverlust bei partiellen Frontend-Calls (ForSale-Toggle schickt nur {status}, Charakterbogen-Save schickt nur {traits,note}). +4 Regressionstests (PartialUpdateTests): ForSale-Toggle, Charakterbogen-Save, EditForm-ohne-Import-Felder, minimaler POST — alle grueen (137/137 gesamt). --- GerbilManager.Tests/PartialUpdateTests.cs | 143 ++++++++++++++++++ GerbilManagerWebAPI/Dtos/ApiDtos.cs | 4 +- .../Endpoints/GerbilEndpoints.cs | 48 +++--- 3 files changed, 172 insertions(+), 23 deletions(-) create mode 100644 GerbilManager.Tests/PartialUpdateTests.cs diff --git a/GerbilManager.Tests/PartialUpdateTests.cs b/GerbilManager.Tests/PartialUpdateTests.cs new file mode 100644 index 0000000..60f14f8 --- /dev/null +++ b/GerbilManager.Tests/PartialUpdateTests.cs @@ -0,0 +1,143 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace GerbilManager.Tests; + +/// 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. +public class PartialUpdateTests : IClassFixture +{ + 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; + + /// Create a fully-populated gerbil and return its id. + private async Task 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") ?? ""); + } +} diff --git a/GerbilManagerWebAPI/Dtos/ApiDtos.cs b/GerbilManagerWebAPI/Dtos/ApiDtos.cs index 9d2a66c..983583c 100644 --- a/GerbilManagerWebAPI/Dtos/ApiDtos.cs +++ b/GerbilManagerWebAPI/Dtos/ApiDtos.cs @@ -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, diff --git a/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs b/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs index ee47ff4..b62dd8e 100644 --- a/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs @@ -39,19 +39,18 @@ namespace GerbilManagerWebAPI.Endpoints if (string.IsNullOrWhiteSpace(input.Name)) return TypedResults.ValidationProblem(new Dictionary { ["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> (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(); - 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); }