feat(triage): Ticket-Fixes (Daten + Code) + prod-fähige Triage
Daten-Fixes (conflict-decisions.json, re-ingest-stabil) für ~30 Tickets:
Merges (Jamie/Hiro/Mino/Jana/Blacky/Sakura/Malou/Socke→Marty), Eltern-Korrekturen
(Jacky/Idefix/Ichika/Roni/Ethan), Kruke→Kuke (+ Todesdatum), Targa-Wurf R14 + Druna,
Stacy/Merle/Domi/Eliza; Joghurt-Phantomwurf entfernt.
Code-Fixes:
- Gaida & alle Verstorbenen: Status wird aus Todesdatum/Abgabe abgeleitet
(Program.cs Startup-Sweep heilt Altfälle; IngestResolved re-derived nach Freeze).
- CoCo: Scheckungsart wird bei jeder Schecke angezeigt (Platzhalter wenn leer).
- M-Wurf/Gale: über-gemergte Fremdtiere via neuem litterChildren-Override entfernt.
- renameTo eltern-verknüpfungssicher (Quell-Name im Index); dateOfDeath als Override.
Prod-fähige Triage (API):
- GET /feedback/{id} + GET /feedback?status= (kein 2-MB-Dump).
- POST /import/ingest-resolved/upload (multipart) → Ingest gegen Prod ohne SSH.
Tests: 280 Backend, 149 Frontend, alle Python, betroffene Playwright grün.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,13 @@ public sealed class ApiFactory : WebApplicationFactory<Program>
|
||||
public string ContractRoot { get; } =
|
||||
Path.Combine(Path.GetTempPath(), $"gerbil-contract-tests-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>Optionaler Isolations-Override für den Import-Quellordner (Import:SourcePath),
|
||||
/// damit der Upload-Ingest-Test in einem Temp-Verzeichnis arbeitet statt im echten Repo.</summary>
|
||||
public string? ImportSourcePath { get; init; }
|
||||
|
||||
/// <summary>Optionaler Isolations-Override für den Foto-Ordner (Photos:RootPath).</summary>
|
||||
public string? PhotosRootPath { get; init; }
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
@@ -37,6 +44,8 @@ public sealed class ApiFactory : WebApplicationFactory<Program>
|
||||
builder.UseSetting("ConnectionStrings:gerbilmanager",
|
||||
"Host=localhost;Database=test;Username=test;Password=test");
|
||||
builder.UseSetting("Contracts:RootPath", ContractRoot);
|
||||
if (ImportSourcePath is not null) builder.UseSetting("Import:SourcePath", ImportSourcePath);
|
||||
if (PhotosRootPath is not null) builder.UseSetting("Photos:RootPath", PhotosRootPath);
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
|
||||
@@ -500,6 +500,38 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_single_feedback_by_id_and_status_filter()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Zwei Tickets mit unterscheidbaren Namen anlegen.
|
||||
var open = await client.PostAsJsonAsync("/feedback", new { message = "Offenes Ticket", context = "gerbil-detail", entityName = "FilterOpen" });
|
||||
var openId = JsonDocument.Parse(await open.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString()!;
|
||||
var other = await client.PostAsJsonAsync("/feedback", new { message = "Zweites Ticket", context = "gerbil-detail", entityName = "FilterOther" });
|
||||
var otherId = JsonDocument.Parse(await other.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString()!;
|
||||
|
||||
// Einzel-GET liefert genau dieses Ticket (mit attachments-Feld).
|
||||
var single = JsonDocument.Parse(await client.GetStringAsync($"/feedback/{openId}")).RootElement;
|
||||
Assert.Equal("FilterOpen", single.GetProperty("entityName").GetString());
|
||||
Assert.Equal(JsonValueKind.Array, single.GetProperty("attachments").ValueKind);
|
||||
|
||||
// Unbekannte Id -> 404.
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/feedback/{Guid.NewGuid()}")).StatusCode);
|
||||
|
||||
// Zweites Ticket auf Answered setzen, dann Status-Filter prüfen.
|
||||
await client.PutAsJsonAsync($"/feedback/{otherId}", new { status = "Answered" });
|
||||
var answered = JsonDocument.Parse(await client.GetStringAsync("/feedback?status=Answered")).RootElement;
|
||||
Assert.Contains(answered.EnumerateArray(), f => f.GetProperty("id").GetString() == otherId);
|
||||
Assert.DoesNotContain(answered.EnumerateArray(), f => f.GetProperty("id").GetString() == openId);
|
||||
|
||||
// Kombinierter Filter (Open,Answered) enthält beide.
|
||||
var both = JsonDocument.Parse(await client.GetStringAsync("/feedback?status=Open,Answered")).RootElement;
|
||||
var ids = both.EnumerateArray().Select(f => f.GetProperty("id").GetString()).ToHashSet();
|
||||
Assert.Contains(openId, ids);
|
||||
Assert.Contains(otherId, ids);
|
||||
}
|
||||
|
||||
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||
{
|
||||
Id = id,
|
||||
|
||||
61
GerbilManager.Tests/ImportIngestUploadEndpointTests.cs
Normal file
61
GerbilManager.Tests/ImportIngestUploadEndpointTests.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// POST /import/ingest-resolved/upload — Upload-Variante des Ingests: eine hochgeladene
|
||||
/// resolved_import.json wird serverseitig in den Import-Quellordner gestaged und direkt ingested.
|
||||
/// Damit lässt sich der Import gegen PROD fahren, ohne die Datei per SSH/docker cp in den Container
|
||||
/// zu kopieren. Der Test isoliert Quell-/Fotoordner über ApiFactory in Temp-Verzeichnisse.
|
||||
/// </summary>
|
||||
public class ImportIngestUploadEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Upload_stages_resolved_json_and_runs_ingest()
|
||||
{
|
||||
var srcDir = Path.Combine(Path.GetTempPath(), "ingest-upload-" + Guid.NewGuid().ToString("N"));
|
||||
var photoDir = Path.Combine(Path.GetTempPath(), "ingest-upload-photos-" + Guid.NewGuid().ToString("N"));
|
||||
using var factory = new ApiFactory { ImportSourcePath = srcDir, PhotosRootPath = photoDir };
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Minimaler, aber valider Payload (leere Sammlungen) — genügt, um Staging + Ingest-Lauf zu prüfen.
|
||||
const string json = "{\"Gerbils\":[],\"Litters\":[],\"Contacts\":[],\"SaleContracts\":[],\"GerbilPhotos\":[]}";
|
||||
|
||||
using var form = new MultipartFormDataContent();
|
||||
var file = new ByteArrayContent(Encoding.UTF8.GetBytes(json));
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
form.Add(file, "resolved", "resolved_import.json");
|
||||
|
||||
var resp = await client.PostAsync("/import/ingest-resolved/upload", form);
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.DoesNotContain("Error:", body); // Ingest lief, keine "file not found"-Fehlermeldung
|
||||
|
||||
// Die hochgeladene Datei wurde an den erwarteten Ort gestaged.
|
||||
Assert.True(File.Exists(Path.Combine(srcDir, "resolved_import.json")));
|
||||
|
||||
try { Directory.Delete(srcDir, recursive: true); } catch { /* best effort */ }
|
||||
try { Directory.Delete(photoDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_rejects_empty_file_with_400()
|
||||
{
|
||||
var srcDir = Path.Combine(Path.GetTempPath(), "ingest-upload-" + Guid.NewGuid().ToString("N"));
|
||||
using var factory = new ApiFactory { ImportSourcePath = srcDir };
|
||||
var client = factory.CreateClient();
|
||||
|
||||
using var form = new MultipartFormDataContent();
|
||||
var file = new ByteArrayContent(Array.Empty<byte>());
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
form.Add(file, "resolved", "resolved_import.json");
|
||||
|
||||
var resp = await client.PostAsync("/import/ingest-resolved/upload", form);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
|
||||
try { Directory.Delete(srcDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
@@ -257,5 +257,34 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal(ContractId, contract.Id);
|
||||
Assert.Single(contract.Animals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestResolved_rederives_status_after_deceased_override()
|
||||
{
|
||||
// Ticket 37ab228a "Gaida": ein verstorbenes Tier blieb nach dem Ingest auf Status
|
||||
// 'Breeding' hängen, weil der Freeze das Todesdatum aus dem Override zurückschrieb,
|
||||
// ohne den Status neu abzuleiten. Der Ingest muss GerbilStatusService.Apply erneut aufrufen.
|
||||
using var db = NewDb();
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", _dir } })
|
||||
.Build();
|
||||
|
||||
db.GerbilOverrides.Add(new GerbilOverride
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = FatherId,
|
||||
EntityName = "Papa",
|
||||
IsVerified = false, // "geschützt" — wird trotzdem angewandt
|
||||
OverrideJson = "{\"dateOfDeath\":\"2025-10-09\"}",
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
|
||||
var father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
||||
Assert.NotNull(father.DateOfDeath); // Todesdatum aus Override angewandt
|
||||
Assert.Equal(GerbilStatus.Deceased, father.Status); // ... und Status daraus abgeleitet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user