Merge feature/feat-14a: Charakterbogen backend (Gerbil.CharacterTraits + CharacterNote) + sale-ad request extension [god-QA: 58]

This commit is contained in:
2026-06-06 09:15:36 +02:00
10 changed files with 1203 additions and 4 deletions

View File

@@ -0,0 +1,75 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.RegularExpressions;
using GerbilManagerWebAPI.SaleAd;
namespace GerbilManager.Tests;
/// <summary>FEAT-14a: Charakterbogen (traits + note) persists/round-trips, and the
/// sale-ad prompt includes the character info.</summary>
public class GerbilCharacterTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
public GerbilCharacterTests(ApiFactory factory) => _client = factory.CreateClient();
[Fact]
public async Task CharacterTraits_and_note_round_trip()
{
var create = await _client.PostAsync("/gerbils", JsonContent.Create(new
{
name = "Charakter Test",
gender = "female",
characterTraits = new[] { "zutraulich", "handzahm" },
characterNote = "sehr aktiv und neugierig",
}));
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
var created = await create.Content.ReadAsStringAsync();
// POST response is the created GerbilDto — traits serialise straight back
Assert.Contains("zutraulich", created);
Assert.Contains("handzahm", created);
Assert.Contains("sehr aktiv und neugierig", created);
var id = Regex.Match(created, "\"id\":\"([^\"]+)\"").Groups[1].Value;
Assert.NotEqual("", id);
// re-read from the DB to prove persistence (not just echo)
var fetched = await _client.GetStringAsync($"/gerbils/{id}");
Assert.Contains("zutraulich", fetched);
Assert.Contains("handzahm", fetched);
Assert.Contains("sehr aktiv und neugierig", fetched);
}
[Fact]
public async Task Omitting_traits_defaults_to_empty_list()
{
var create = await _client.PostAsync("/gerbils", JsonContent.Create(new
{
name = "Ohne Charakter",
gender = "male",
}));
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
var body = await create.Content.ReadAsStringAsync();
Assert.Contains("\"characterTraits\":[]", body.Replace(" ", ""));
}
[Fact]
public void SaleAd_prompt_includes_traits_and_note()
{
var request = new SaleAdRequest(
Animals: new List<SaleAdAnimal>
{
new("Bella", "Agouti", "2025-01-15", "verspielt",
Traits: new List<string> { "zutraulich", "handzahm" },
CharacterNote: "liebt Sonnenblumenkerne"),
},
StatusLine: "FREI",
Hints: "");
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
Assert.Contains("Charakter: zutraulich, handzahm", prompt);
Assert.Contains("liebt Sonnenblumenkerne", prompt);
}
}

View File

@@ -49,6 +49,19 @@ public class ApplicationContext : DbContext
e.Property(g => g.Gender).HasConversion<string>(); e.Property(g => g.Gender).HasConversion<string>();
e.Property(g => g.Status).HasConversion<string>(); e.Property(g => g.Status).HasConversion<string>();
// FEAT-14: character traits stored as a JSON text column (works on both
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
v => string.IsNullOrEmpty(v)
? new List<string>()
: System.Text.Json.JsonSerializer.Deserialize<List<string>>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<string>());
var traitsComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<string>>(
(a, b) => (a ?? new List<string>()).SequenceEqual(b ?? new List<string>()),
v => v == null ? 0 : v.Aggregate(0, (h, s) => HashCode.Combine(h, s.GetHashCode())),
v => v.ToList());
e.Property(g => g.CharacterTraits).HasConversion(traitsConverter, traitsComparer);
e.HasOne(g => g.Litter).WithMany() e.HasOne(g => g.Litter).WithMany()
.HasForeignKey(g => g.LitterId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(g => g.LitterId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(g => g.OriginContact).WithMany() e.HasOne(g => g.OriginContact).WithMany()

View File

@@ -25,7 +25,9 @@ namespace GerbilManagerWebAPI.Dtos
string? Notes, string? Notes,
string? ImportSource, string? ImportSource,
string? ExternalRef, string? ExternalRef,
string? OriginBreeder); string? OriginBreeder,
List<string> CharacterTraits,
string? CharacterNote);
public record LitterDto( public record LitterDto(
Guid Id, Guid Id,
@@ -71,7 +73,9 @@ namespace GerbilManagerWebAPI.Dtos
string? Notes, string? Notes,
string? ImportSource, string? ImportSource,
string? ExternalRef, string? ExternalRef,
string? OriginBreeder); string? OriginBreeder,
List<string>? CharacterTraits,
string? CharacterNote);
public record LitterInput( public record LitterInput(
string Name, string Name,

View File

@@ -95,11 +95,14 @@ namespace GerbilManagerWebAPI.Endpoints
g.ImportSource = i.ImportSource; g.ImportSource = i.ImportSource;
g.ExternalRef = i.ExternalRef; g.ExternalRef = i.ExternalRef;
g.OriginBreeder = i.OriginBreeder; g.OriginBreeder = i.OriginBreeder;
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
g.CharacterNote = i.CharacterNote;
} }
internal static GerbilDto ToDto(Gerbil g) => new( internal static GerbilDto ToDto(Gerbil g) => new(
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId, g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath, g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder); g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
g.CharacterTraits, g.CharacterNote);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddCharacterFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CharacterNote",
table: "Gerbils",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CharacterTraits",
table: "Gerbils",
type: "text",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CharacterNote",
table: "Gerbils");
migrationBuilder.DropColumn(
name: "CharacterTraits",
table: "Gerbils");
}
}
}

View File

@@ -661,6 +661,13 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("CauseOfDeath") b.Property<string>("CauseOfDeath")
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("CharacterNote")
.HasColumnType("text");
b.Property<string>("CharacterTraits")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ColorVarietyId") b.Property<Guid?>("ColorVarietyId")
.HasColumnType("uuid"); .HasColumnType("uuid");

View File

@@ -60,6 +60,13 @@ namespace GerbilManagerWebAPI.Models
/// Kept in sync automatically on save (see ApplicationContext.SaveChanges). Gridify-filterable /// Kept in sync automatically on save (see ApplicationContext.SaveChanges). Gridify-filterable
/// so "clan kleine chaoten" matches "Clan-Kleine-Chaoten" (client strips separators too).</summary> /// so "clan kleine chaoten" matches "Clan-Kleine-Chaoten" (client strips separators too).</summary>
public string? NameSearch { get; set; } public string? NameSearch { get; set; }
/// <summary>FEAT-14: Charakterbogen trait labels (opaque to the backend — the
/// {key,label} vocabulary lives frontend-side). Stored as a JSON text column.</summary>
public List<string> CharacterTraits { get; set; } = new();
/// <summary>FEAT-14: free-text character note; feeds the AI Verkaufstext.</summary>
public string? CharacterNote { get; set; }
} }
/// <summary>Shared normalisation for the separator-insensitive name search.</summary> /// <summary>Shared normalisation for the separator-insensitive name search.</summary>

View File

@@ -9,7 +9,11 @@ namespace GerbilManagerWebAPI.SaleAd
string? Farbschlag, string? Farbschlag,
/// <summary>ISO "YYYY-MM-DD" (frontend sends the DTO string verbatim).</summary> /// <summary>ISO "YYYY-MM-DD" (frontend sends the DTO string verbatim).</summary>
string? DateOfBirth, string? DateOfBirth,
string? Notes); string? Notes,
/// <summary>FEAT-14: Charakterbogen trait LABELS (German, human-readable) for the prompt.</summary>
List<string>? Traits = null,
/// <summary>FEAT-14: free-text character note for the prompt.</summary>
string? CharacterNote = null);
public sealed record SaleAdRequest( public sealed record SaleAdRequest(
List<SaleAdAnimal> Animals, List<SaleAdAnimal> Animals,

View File

@@ -87,6 +87,10 @@ namespace GerbilManagerWebAPI.SaleAd
sb.Append($" | geboren am {FormatGermanDate(animal.DateOfBirth)}"); sb.Append($" | geboren am {FormatGermanDate(animal.DateOfBirth)}");
if (!string.IsNullOrWhiteSpace(animal.Notes)) if (!string.IsNullOrWhiteSpace(animal.Notes))
sb.Append($" | Notizen: {animal.Notes}"); sb.Append($" | Notizen: {animal.Notes}");
if (animal.Traits is { Count: > 0 })
sb.Append($" | Charakter: {string.Join(", ", animal.Traits)}");
if (!string.IsNullOrWhiteSpace(animal.CharacterNote))
sb.Append($" | Charakter-Notiz: {animal.CharacterNote}");
sb.AppendLine(); sb.AppendLine();
} }
if (!string.IsNullOrWhiteSpace(request.Hints)) if (!string.IsNullOrWhiteSpace(request.Hints))