Merge branch 'worktree-agent-ae59040b377a324cb'

# Conflicts:
#	GerbilManagerWebAPI/ApplicationContext.cs
#	GerbilManagerWebAPI/Program.cs
#	gerbil-manager-web/e2e/mock-data.ts
#	gerbil-manager-web/src/pages/GerbilDetailPage.tsx
#	gerbil-manager-web/src/strings/de.ts
This commit is contained in:
2026-06-22 23:10:18 +02:00
16 changed files with 2520 additions and 2 deletions

View File

@@ -0,0 +1,211 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace GerbilManager.Tests;
/// <summary>
/// EXHIBITION: show/exhibition results &amp; awards per animal (/exhibitions).
/// - POST creates a row; GET (optionally filtered by gerbilId) returns it, newest first.
/// - PUT edits, DELETE removes; unknown ids return 404; an empty EventName is rejected (400).
/// - CRITICAL: exhibition rows survive the import re-ingest wipe (loose, FK-free GerbilId).
/// </summary>
public class ExhibitionEndpointTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
public ExhibitionEndpointTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Post_creates_and_get_filters_by_gerbilId()
{
var client = _factory.CreateClient();
var gerbilId = Guid.NewGuid();
var resp = await client.PostAsJsonAsync("/exhibitions", new
{
gerbilId,
entityName = "Krümel",
eventName = "Nationale Rennmausschau 2026",
date = "2026-03-15T00:00:00Z",
placement = "1. Platz",
award = "Best in Show",
note = "Sehr ausgeglichenes Tier.",
});
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
var id = created.GetProperty("id").GetString();
Assert.False(string.IsNullOrEmpty(id));
Assert.Equal("Nationale Rennmausschau 2026", created.GetProperty("eventName").GetString());
Assert.Equal("1. Platz", created.GetProperty("placement").GetString());
Assert.Equal("Best in Show", created.GetProperty("award").GetString());
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
// GET filtered to this gerbil returns the row.
var listed = JsonDocument.Parse(await client.GetStringAsync($"/exhibitions?gerbilId={gerbilId}")).RootElement;
Assert.Contains(listed.EnumerateArray(),
x => x.GetProperty("id").GetString() == id
&& x.GetProperty("eventName").GetString() == "Nationale Rennmausschau 2026");
// GET filtered to a DIFFERENT gerbil does not.
var other = JsonDocument.Parse(await client.GetStringAsync($"/exhibitions?gerbilId={Guid.NewGuid()}")).RootElement;
Assert.DoesNotContain(other.EnumerateArray(), x => x.GetProperty("id").GetString() == id);
}
[Fact]
public async Task Crud_lifecycle_edit_and_delete()
{
var client = _factory.CreateClient();
var create = await client.PostAsJsonAsync("/exhibitions", new
{
eventName = "Lokalschau",
entityName = "Balu",
});
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
// Edit: change placement + add a date.
var edit = await client.PutAsJsonAsync($"/exhibitions/{id}", new
{
placement = "2. Platz",
date = "2025-11-01T00:00:00Z",
});
Assert.Equal(HttpStatusCode.OK, edit.StatusCode);
var edited = JsonDocument.Parse(await edit.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("2. Platz", edited.GetProperty("placement").GetString());
Assert.Equal("Lokalschau", edited.GetProperty("eventName").GetString());
// Delete -> 204, then 404 on subsequent edit/delete.
var del = await client.DeleteAsync($"/exhibitions/{id}");
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/exhibitions/{id}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/exhibitions/{id}", new { eventName = "x" })).StatusCode);
}
[Fact]
public async Task Post_rejects_empty_event_name()
{
var client = _factory.CreateClient();
var resp = await client.PostAsJsonAsync("/exhibitions", new { eventName = " " });
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Put_and_delete_unknown_id_return_404()
{
var client = _factory.CreateClient();
var missing = Guid.NewGuid();
Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/exhibitions/{missing}", new { eventName = "x" })).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/exhibitions/{missing}")).StatusCode);
}
[Fact]
public async Task Exhibition_survives_ingest_wipe()
{
// Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests).
var dir = Path.Combine(Path.GetTempPath(), "exhibition-ingest-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var contactId = Guid.NewGuid();
var fatherId = Guid.NewGuid();
var motherId = Guid.NewGuid();
var litterId = Guid.NewGuid();
var data = new
{
Contacts = new[]
{
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
},
Litters = new[]
{
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
},
Gerbils = new[]
{
Animal(fatherId, "Papa", "male", contactId),
Animal(motherId, "Mama", "female", contactId),
},
GerbilPhotos = Array.Empty<object>(),
};
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseInMemoryDatabase("exhibition-ingest-" + Guid.NewGuid().ToString("N"))
.Options;
using var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
// An exhibition result referencing the gerbil that the wipe will delete/recreate.
var exhId = Guid.NewGuid();
db.ExhibitionResults.Add(new ExhibitionResult
{
Id = exhId,
GerbilId = fatherId,
EntityName = "Papa",
EventName = "Nationale Rennmausschau 2026",
Date = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc),
Placement = "1. Platz",
Award = "Best in Show",
Note = "Tolles Tier.",
CreatedAt = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
.Build();
// Run the ingest wipe + reload.
var result = await new IngestResolvedService(db, config, null!).RunAsync();
Assert.Contains("Ingestion successful!", result);
// Gerbils/litters/contacts were wiped & re-created, but the exhibition row is untouched.
var survivor = await db.ExhibitionResults.SingleAsync(x => x.Id == exhId);
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
Assert.Equal("Papa", survivor.EntityName);
Assert.Equal("Nationale Rennmausschau 2026", survivor.EventName);
Assert.Equal("1. Platz", survivor.Placement);
Assert.Equal("Best in Show", survivor.Award);
Assert.Equal(1, await db.ExhibitionResults.CountAsync());
}
finally
{
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
}
}
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
{
Id = id,
Name = name,
Gender = gender,
Status = "Breeding",
LitterId = (Guid?)null,
OriginContactId = contactId,
ReceiverContactId = (Guid?)null,
EnclosureId = (Guid?)null,
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
DateOfBirth = "2025-01-01",
DateOfDeath = (string?)null,
CauseOfDeath = (string?)null,
GoHomeDate = (string?)null,
Genotype = "aa CC DD EE GG PP spsp rere",
Notes = "",
ImportSource = "docx-export",
ExternalRef = "ext-" + name,
RawImportData = "{}",
OriginBreeder = "Test Zucht",
NameSearch = name.ToLowerInvariant(),
CharacterTraits = Array.Empty<string>(),
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true,
};
}