feat(warteliste): Nachfrage/Warteliste für Interessenten

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 22:43:18 +02:00
parent 5e14124322
commit d25d2ee152
17 changed files with 2724 additions and 0 deletions

View File

@@ -0,0 +1,204 @@
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>
/// WAITLIST (RennmausPro nachfrage_tb): the prospective-buyer waiting list.
/// - Full CRUD round-trip: POST -> GET (list + by id) -> PUT (status change) -> DELETE.
/// - Validation: an entry with neither contact nor name, and an unknown status, are 400.
/// - CRITICAL: waiting-list rows survive the import re-ingest wipe (loose, FK-free ContactId).
/// </summary>
public class WaitingListEndpointTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
public WaitingListEndpointTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Crud_round_trip()
{
var client = _factory.CreateClient();
// CREATE
var contactId = Guid.NewGuid();
var create = await client.PostAsJsonAsync("/waiting-list", new
{
contactId,
contactName = "Familie Sonntag",
wishColor = "Schwarz",
wishGender = "female",
requestedAt = "2026-06-01T00:00:00Z",
status = "offen",
note = "möchte zwei Weibchen",
});
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
var created = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement;
var id = created.GetProperty("id").GetString()!;
Assert.Equal("offen", created.GetProperty("status").GetString());
Assert.Equal("Schwarz", created.GetProperty("wishColor").GetString());
Assert.Equal(contactId.ToString(), created.GetProperty("contactId").GetString());
// GET by id
var byId = JsonDocument.Parse(await client.GetStringAsync($"/waiting-list/{id}")).RootElement;
Assert.Equal("Familie Sonntag", byId.GetProperty("contactName").GetString());
// LIST contains it
var list = JsonDocument.Parse(await client.GetStringAsync("/waiting-list")).RootElement;
Assert.Contains(list.EnumerateArray(), e => e.GetProperty("id").GetString() == id);
// UPDATE: set fulfilled
var update = await client.PutAsJsonAsync($"/waiting-list/{id}", new
{
contactId,
contactName = "Familie Sonntag",
wishColor = "Schwarz",
wishGender = "female",
requestedAt = "2026-06-01T00:00:00Z",
status = "erfuellt",
note = "erledigt",
});
Assert.Equal(HttpStatusCode.OK, update.StatusCode);
var updated = JsonDocument.Parse(await update.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("erfuellt", updated.GetProperty("status").GetString());
// DELETE
var del = await client.DeleteAsync($"/waiting-list/{id}");
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
var after = await client.GetAsync($"/waiting-list/{id}");
Assert.Equal(HttpStatusCode.NotFound, after.StatusCode);
}
[Fact]
public async Task Post_rejects_empty_contact_and_name()
{
var client = _factory.CreateClient();
var resp = await client.PostAsJsonAsync("/waiting-list", new { status = "offen", wishColor = "Gold" });
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Post_rejects_unknown_status()
{
var client = _factory.CreateClient();
var resp = await client.PostAsJsonAsync("/waiting-list", new { contactName = "Test", status = "irgendwas" });
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Empty_status_defaults_to_offen()
{
var client = _factory.CreateClient();
var resp = await client.PostAsJsonAsync("/waiting-list", new { contactName = "Ohne Status" });
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("offen", created.GetProperty("status").GetString());
}
[Fact]
public async Task WaitingList_survives_ingest_wipe()
{
// Fresh in-memory DB seeded with a resolved import file (mirrors FeedbackEndpointTests).
var dir = Path.Combine(Path.GetTempPath(), "waitlist-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("waitlist-ingest-" + Guid.NewGuid().ToString("N"))
.Options;
using var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
// A waiting-list entry referencing the contact that the wipe will delete.
var entryId = Guid.NewGuid();
db.WaitingListEntries.Add(new WaitingListEntry
{
Id = entryId,
ContactId = contactId,
ContactName = "Test Breeder",
WishColor = "Schwarz",
WishGender = "female",
RequestedAt = DateTime.UtcNow,
Status = "offen",
Note = "wartet",
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);
// Contacts were wiped & re-created, but the waiting-list entry is untouched.
var survivor = await db.WaitingListEntries.SingleAsync(e => e.Id == entryId);
Assert.Equal(contactId, survivor.ContactId); // loose id preserved even though the contact row was deleted/recreated
Assert.Equal("Test Breeder", survivor.ContactName);
Assert.Equal("offen", survivor.Status);
Assert.Equal(1, await db.WaitingListEntries.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,
};
}