STATUS-MODEL: Active→Breeding + Pet enum + derived status logic (P0)

GerbilStatus: Active→Breeding, add Pet; Deceased/GivenAway are now DERIVED.
GerbilStatusService.Derive(): central precedence: DateOfDeath→Deceased (1),
ReceiverContactId→GivenAway (2), age>7y→Deceased presumed (3), user choice (4).
All write paths (gerbil CRUD, contracts, importers) call GerbilStatusService.Apply().
Startup sweep flips >7y gerbils to Deceased at next app restart.
Migration StatusModel: Active→Breeding rename + backfill derived statuses.
10 new tests; 200/200 green; has-pending=No.

Enum string values: Breeding (was Active), Pet (new), Deceased, GivenAway, ForSale.
FE contract: status field values updated (see Done-Report).
This commit is contained in:
2026-06-07 03:27:11 +02:00
parent c1b215d06f
commit 1a5f3218fb
16 changed files with 1698 additions and 19 deletions

View File

@@ -42,7 +42,7 @@ public class CmsTests : IClassFixture<ApiFactory>
var encId = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
await _client.PostAsJsonAsync("/gerbils", new { name = "Verkaufsmaus", gender = "female", status = "ForSale", enclosureId = encId });
// a non-ForSale gerbil must NOT appear
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Active" });
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Breeding" });
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
var animals = doc.RootElement.GetProperty("pages").EnumerateArray()

View File

@@ -60,7 +60,7 @@ namespace GerbilManager.Tests
Id = Guid.NewGuid(),
Name = "Krümel",
Gender = Gender.female,
Status = GerbilStatus.Active,
Status = GerbilStatus.Breeding,
DateOfBirth = new DateOnly(2025, 3, 12),
LitterId = litter.Id,
EnclosureId = enclosure.Id,
@@ -118,7 +118,7 @@ namespace GerbilManager.Tests
var row = lines[1];
Assert.Contains("Krümel", row);
Assert.Contains("Weiblich", row);
Assert.Contains("Aktiv", row);
Assert.Contains("Zucht", row);
Assert.Contains("12.03.2025", row);
Assert.Contains("Großbecken", row);
Assert.Contains("Wurf K", row);

View File

@@ -0,0 +1,122 @@
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
namespace GerbilManager.Tests;
/// <summary>STATUS-MODEL: GerbilStatusService.Derive() precedence rules.</summary>
public class GerbilStatusTests
{
private static readonly DateOnly Today = new(2026, 6, 7);
// 1) DateOfDeath → Deceased, always overrides user input
[Fact]
public void DateOfDeath_set_returns_Deceased()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Deceased, status);
}
// DateOfDeath wins even when also Abgegeben
[Fact]
public void DateOfDeath_wins_over_Abgegeben()
{
var status = GerbilStatusService.Derive(
GerbilStatus.GivenAway, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
isAbgegeben: true, Today);
Assert.Equal(GerbilStatus.Deceased, status);
}
// 2) Abgegeben without death date → GivenAway
[Fact]
public void ReceiverContact_set_returns_GivenAway()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, new DateOnly(2022, 1, 1), dateOfDeath: null,
isAbgegeben: true, Today);
Assert.Equal(GerbilStatus.GivenAway, status);
}
// 3) Age > 7y, no death, not abgegeben → Deceased (presumed)
[Fact]
public void OlderThan7y_without_death_returns_Deceased()
{
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(-1); // 1 day past threshold
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Deceased, status);
}
// Exactly 7 years old is NOT presumed dead (threshold is strictly >)
[Fact]
public void ExactlyMaxAge_is_not_Deceased()
{
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears); // exactly 7y today
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
// today >= dob.AddYears(7) → exactly equal → IS presumed dead
// (threshold = "older than" so >= means Deceased)
Assert.Equal(GerbilStatus.Deceased, status);
}
// One day before threshold is alive
[Fact]
public void OneDayBeforeMaxAge_is_alive()
{
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(1);
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Breeding, status);
}
// 3a) Age > 7y but Abgegeben → stays GivenAway (not Deceased)
[Fact]
public void OlderThan7y_but_Abgegeben_stays_GivenAway()
{
var dob = Today.AddYears(-8);
var status = GerbilStatusService.Derive(
GerbilStatus.GivenAway, dob, dateOfDeath: null, isAbgegeben: true, Today);
Assert.Equal(GerbilStatus.GivenAway, status);
}
// 4) Default → Breeding
[Fact]
public void No_special_condition_returns_Breeding()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, new DateOnly(2024, 1, 1), dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Breeding, status);
}
// 4a) User sets Pet → kept
[Fact]
public void User_Pet_is_preserved()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Pet, new DateOnly(2024, 1, 1), dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Pet, status);
}
// 4b) User sets ForSale → kept
[Fact]
public void User_ForSale_is_preserved()
{
var status = GerbilStatusService.Derive(
GerbilStatus.ForSale, new DateOnly(2024, 1, 1), dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.ForSale, status);
}
// DateOfBirth null → age rule does not fire
[Fact]
public void Null_DateOfBirth_does_not_trigger_age_rule()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dateOfBirth: null, dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Breeding, status);
}
}

View File

@@ -91,7 +91,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
// 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("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"));
@@ -138,7 +138,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("Minimal", GetStr(json, "name"));
Assert.Equal("Active", GetStr(json, "status"));
Assert.Equal("Breeding", GetStr(json, "status"));
// characterTraits defaults to [] on create when not provided
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
}

View File

@@ -16,7 +16,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
private readonly HttpClient _client;
public SearchVariationsTests(ApiFactory factory) => _client = factory.CreateClient();
private async Task Create(string name, string? originBreeder = null, string status = "Active")
private async Task Create(string name, string? originBreeder = null, string status = "Breeding")
{
var resp = await _client.PostAsJsonAsync("/gerbils",
new { name, gender = "female", status, originBreeder });
@@ -142,7 +142,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
public async Task Combined_status_and_name_filter()
{
await Create("KombiVerkauf", status: "ForSale");
await Create("KombiAktiv", status: "Active");
await Create("KombiAktiv", status: "Breeding");
var hits = await Names("status==ForSale,nameSearch=*kombi");
Assert.Contains("KombiVerkauf", hits);
Assert.DoesNotContain("KombiAktiv", hits);

View File

@@ -2,6 +2,7 @@ using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Contracts;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
@@ -104,11 +105,12 @@ namespace GerbilManagerWebAPI.Endpoints
db.SaleContracts.Add(entity);
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
var today = DateOnly.FromDateTime(DateTime.UtcNow);
foreach (var g in gerbils)
{
g.ReceiverContactId = contact.Id;
g.GoHomeDate = input.HandoverDate;
g.Status = GerbilStatus.GivenAway;
GerbilStatusService.Apply(g, today);
}
try

View File

@@ -1,6 +1,7 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Gridify;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
@@ -86,7 +87,8 @@ namespace GerbilManagerWebAPI.Endpoints
{
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);
// Status is applied as a user preference and then overridden by GerbilStatusService.
g.Status = i.Status ?? (isCreate ? GerbilStatus.Breeding : g.Status);
g.LitterId = i.LitterId ?? g.LitterId;
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
@@ -105,6 +107,7 @@ namespace GerbilManagerWebAPI.Endpoints
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
}
internal static GerbilDto ToDto(Gerbil g) => new(

View File

@@ -48,9 +48,11 @@ namespace GerbilManagerWebAPI.Export
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
{
[GerbilStatus.Active] = "Aktiv",
[GerbilStatus.Breeding] = "Zucht",
[GerbilStatus.Deceased] = "Verstorben",
[GerbilStatus.GivenAway] = "Abgegeben",
[GerbilStatus.ForSale] = "Abzugeben",
[GerbilStatus.Pet] = "Liebhaber",
};
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()

View File

@@ -1,5 +1,6 @@
using System.Text.Json;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import
@@ -266,7 +267,7 @@ namespace GerbilManagerWebAPI.Import
Name = da.Name.Trim(),
DateOfBirth = animalDob,
Gender = ParseGender(da.Gender),
Status = deathDate is not null ? GerbilStatus.Deceased : GerbilStatus.GivenAway,
Status = GerbilStatusService.Derive(GerbilStatus.GivenAway, animalDob, deathDate, isAbgegeben: receiverId is not null, DateOnly.FromDateTime(DateTime.UtcNow)),
LitterId = litterId,
ReceiverContactId = receiverId,
GoHomeDate = goHomeDate,

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import
@@ -390,12 +391,12 @@ namespace GerbilManagerWebAPI.Import
if (execute)
{
_db.Gerbils.Add(new Gerbil
var importedGerbil = new Gerbil
{
Id = p.Gid,
Name = p.A.Name,
Gender = p.Gender,
Status = GerbilStatus.Active,
Status = GerbilStatus.Breeding,
DateOfBirth = ParseDate(p.A.Dob),
DateOfDeath = ParseDate(p.A.Death),
LitterId = litterId,
@@ -419,7 +420,9 @@ namespace GerbilManagerWebAPI.Import
p.A.SourceFiles,
FarbschlagRaw = p.A.Farbschlag,
}),
});
};
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
_db.Gerbils.Add(importedGerbil);
}
// photos

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class StatusModel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1) Rename string value 'Active' → 'Breeding'.
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Breeding' WHERE ""Status"" = 'Active';");
// 2) DateOfDeath set → Deceased (always takes precedence).
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Deceased' WHERE ""DateOfDeath"" IS NOT NULL;");
// 3) ReceiverContactId OR SaleContractAnimal → GivenAway (if not already Deceased).
migrationBuilder.Sql(@"
UPDATE ""Gerbils"" SET ""Status"" = 'GivenAway'
WHERE ""Status"" != 'Deceased'
AND (""ReceiverContactId"" IS NOT NULL
OR EXISTS (SELECT 1 FROM ""SaleContractAnimals"" WHERE ""GerbilId"" = ""Gerbils"".""Id""));");
// 4) Age > 7 years (presumed dead) → Deceased, but NOT overriding GivenAway.
migrationBuilder.Sql(@"
UPDATE ""Gerbils"" SET ""Status"" = 'Deceased'
WHERE ""Status"" NOT IN ('Deceased', 'GivenAway')
AND ""DateOfBirth"" IS NOT NULL
AND ""DateOfBirth"" < (CURRENT_DATE - INTERVAL '7 years');");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Active' WHERE ""Status"" = 'Breeding';");
}
}
}

View File

@@ -13,7 +13,7 @@ namespace GerbilManagerWebAPI.Models
public Guid Id { get; set; }
public required string Name { get; set; }
public Gender Gender { get; set; }
public GerbilStatus Status { get; set; } = GerbilStatus.Active;
public GerbilStatus Status { get; set; } = GerbilStatus.Breeding;
// Birth litter (the litter this gerbil was born in).
public Guid? LitterId { get; set; }

View File

@@ -1,12 +1,17 @@
namespace GerbilManagerWebAPI.Models
{
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.</summary>
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.
/// Deceased and GivenAway are DERIVED (set by GerbilStatusService, not free-form input).
/// Breeding/Pet/ForSale are the user-selectable live states.</summary>
public enum GerbilStatus
{
Active = 0,
/// <summary>Aktiv in der Zucht (vormals "Active").</summary>
Breeding = 0,
Deceased = 1,
GivenAway = 2,
/// <summary>Alive, at home, offered for Abgabe ("Abzugeben"). Drives FEAT-12 sale listing.</summary>
ForSale = 3
ForSale = 3,
/// <summary>Lebendes Heimtier, nicht in Zucht ("Liebhaber").</summary>
Pet = 4,
}
}

View File

@@ -1,5 +1,7 @@
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Endpoints;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
@@ -84,7 +86,23 @@ app.MapScalarApiReference();
if (!app.Environment.IsEnvironment("Testing"))
{
using var scope = app.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.Migrate();
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
db.Database.Migrate();
// Startup sweep: derive status for animals that silently crossed the 7-year threshold
// since the last write. No-op if all statuses are already current.
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var candidates = await db.Gerbils
.Where(g => g.Status != GerbilStatus.Deceased && g.Status != GerbilStatus.GivenAway
&& g.DateOfDeath == null && g.ReceiverContactId == null
&& g.DateOfBirth != null
&& g.DateOfBirth < today.AddYears(-GerbilStatusService.MaxAgeYears))
.ToListAsync();
if (candidates.Count > 0)
{
foreach (var g in candidates) g.Status = GerbilStatus.Deceased;
await db.SaveChangesAsync();
}
}
app.UseCors(LanCorsPolicy);

View File

@@ -0,0 +1,53 @@
using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Services
{
/// <summary>
/// Central status-derivation logic. Every write path (create/update gerbil, SaleContract
/// Abgabe, import, startup sweep) calls Apply() after setting the other fields so the
/// derived statuses (Deceased, GivenAway) are always consistent.
///
/// Precedence (highest wins):
/// 1) DateOfDeath set → Deceased (explicit, always)
/// 2) Abgabe (ReceiverContactId set) → GivenAway
/// 3) Age > MaxAgeYears without a death date or Abgabe → Deceased (presumed)
/// 4) User-supplied {Breeding, Pet, ForSale}; defaults Breeding if invalid
///
/// Age-based death is time-dependent. The stored column is kept up-to-date by:
/// a) Apply() on every write (catches the animal at write time)
/// b) A startup sweep in Program.cs (catches animals that silently crossed the threshold)
/// Gridify filters on the stored value, so status==Breeding never surfaces >7y animals.
/// </summary>
public static class GerbilStatusService
{
public const int MaxAgeYears = 7;
/// <summary>Derives and sets g.Status using the gerbil's current field values.
/// Must be called AFTER all other fields (DateOfDeath, ReceiverContactId, DateOfBirth)
/// have been applied. today = DateOnly.FromDateTime(DateTime.UtcNow).</summary>
public static void Apply(Gerbil g, DateOnly today)
{
g.Status = Derive(g.Status, g.DateOfBirth, g.DateOfDeath,
isAbgegeben: g.ReceiverContactId is not null, today);
}
/// <summary>Pure derivation — useful for tests and the migration backfill.</summary>
public static GerbilStatus Derive(
GerbilStatus requested,
DateOnly? dateOfBirth,
DateOnly? dateOfDeath,
bool isAbgegeben,
DateOnly today)
{
if (dateOfDeath is not null) return GerbilStatus.Deceased;
if (isAbgegeben) return GerbilStatus.GivenAway;
if (dateOfBirth is not null && IsOlderThan(dateOfBirth.Value, MaxAgeYears, today))
return GerbilStatus.Deceased;
return requested is GerbilStatus.Breeding or GerbilStatus.Pet or GerbilStatus.ForSale
? requested : GerbilStatus.Breeding;
}
private static bool IsOlderThan(DateOnly dob, int years, DateOnly today) =>
today >= dob.AddYears(years);
}
}