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);
}