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") { // Use name-derived unique externalRef so the DB-1 unique constraint doesn't fire // when multiple tests in the same fixture share the SQLite connection. 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-{name.GetHashCode():X8}", })); 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.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT 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("Breeding", 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.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT 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.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT 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("Breeding", GetStr(json, "status")); // characterTraits defaults to [] on create when not provided Assert.Contains("[]", GetNested(json, "characterTraits") ?? ""); } }